From e9baaa73ac545e8d1855cddb774c75c544a458b5 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:11:46 +0200 Subject: [PATCH 1/2] Fix reset detection and weekly chart readability for 0.8.5 --- CHANGELOG.md | 6 + CodexUsageDock.Tests/UsageDataTests.cs | 5 +- .../WeeklyChartReadabilityTests.cs | 193 ++++++++++++++++++ .../WeeklyResetIntegrationTests.cs | 2 +- CodexUsageDock/CodexUsageDock.csproj | 2 +- CodexUsageDock/Pages/CodexUsageDockPage.cs | 16 +- CodexUsageDock/Pages/UsageDashboardCard.cs | 6 +- CodexUsageDock/WeeklyAllowanceRestoration.cs | 20 +- CodexUsageDock/WeeklyUsageTrendChart.cs | 104 +++++++--- DEVELOPMENT.md | 2 +- README.md | 2 +- USER_GUIDE.md | 4 +- 12 files changed, 314 insertions(+), 48 deletions(-) create mode 100644 CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aced0e..cd3c1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ## [Unreleased] +## [0.8.5] - 2026-10-03 + +### Fixed + +- Avoid false reset markers and broken history lines when an unused quota's estimated reset time moves with each reading. Keep real reset events, remove duplicate restoration lines, and prevent chart labels from overlapping. Show compact billion-token labels and the forecast basis beside the chart, with a legend that reflects the available data. + ## [0.8.4] - 2026-10-03 ### Changed diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 1aeea0c..9ab9977 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.4", CodexUsageDockMetadata.Version); + Assert.Equal("0.8.5", CodexUsageDockMetadata.Version); Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title); } @@ -241,7 +241,8 @@ public void DashboardUsesFullWidthContentWithoutADetailsButton() Assert.DoesNotContain("singleWindow", main.TemplateJson, StringComparison.Ordinal); Assert.Contains("Time elapsed", main.TemplateJson, StringComparison.Ordinal); Assert.Contains("weeklyTrendAvailable", main.TemplateJson, StringComparison.Ordinal); - Assert.DoesNotContain("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("weeklyForecastStatus", main.TemplateJson, StringComparison.Ordinal); Assert.Same(Assert.Single(page.GetContent()), Assert.Single(page.GetContent())); } diff --git a/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs b/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs new file mode 100644 index 0000000..eefbbee --- /dev/null +++ b/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using System.Xml.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace CodexUsageDock.Tests; + +public sealed class WeeklyChartReadabilityTests(ITestOutputHelper output) +{ + private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; + private static readonly DateTimeOffset Now = new(2026, 10, 3, 7, 0, 0, TimeSpan.Zero); + + [Fact] + public void MovingUnusedResetEstimatesKeepOneObservedLineWithoutResetMarkers() + { + var idle = Enumerable.Range(0, 200).Select(index => + { + var at = Now.AddHours(-10).AddMinutes(index * 3); + return new UsageHistoryEntry(at, 100, at.AddDays(7), 10080); + }).ToArray(); + var reset = idle[^1].ResetsAt!.Value; + var history = idle.Append(new UsageHistoryEntry(Now, 95, reset, 10080)).ToArray(); + var chart = Create(history, reset); + var svg = Parse(chart); + + Assert.Single(ObservedLines(svg)); + Assert.Empty(ResetMarkers(svg)); + Assert.Contains("No quota reset or reset-schedule changes", chart.AltText, StringComparison.Ordinal); + } + + [Fact] + public void UnusedEstimateCanSettleBetweenObservationsWhenConsumptionStarts() + { + var previousAt = Now.AddMinutes(-5); + var startedAt = Now.AddMinutes(-2); + UsageHistoryEntry previous = new(previousAt, 100, previousAt.AddDays(7), 10080); + UsageHistoryEntry current = new(Now, 99, startedAt.AddDays(7), 10080); + + Assert.False(WeeklyAllowanceRestoration.IsCycleChange(previous, current)); + Assert.Empty(ResetMarkers(Parse(Create([previous, current], current.ResetsAt!.Value)))); + } + + [Theory] + [InlineData(100)] + [InlineData(80)] + public void FixedCycleChangesArePreservedEvenWithoutAnAllowanceIncrease(double remaining) + { + UsageHistoryEntry previous = new(Now.AddMinutes(-5), remaining, Now.AddDays(3), 10080); + UsageHistoryEntry current = new(Now, remaining, Now.AddDays(6), 10080); + Assert.True(WeeklyAllowanceRestoration.IsCycleChange(previous, current)); + } + + [Fact] + public void ScheduledRolloverAtFullAllowanceIsStillACycleChange() + { + UsageHistoryEntry previous = new(Now.AddMinutes(-5), 100, Now.AddMinutes(-1), 10080); + UsageHistoryEntry current = new(Now, 100, Now.AddDays(7), 10080); + Assert.True(WeeklyAllowanceRestoration.IsCycleChange(previous, current)); + } + + [Fact] + public void MissingWindowMetadataCannotBeAssumedToBeAnUnusedEstimate() + { + var previousAt = Now.AddMinutes(-5); + UsageHistoryEntry previous = new(previousAt, 100, previousAt.AddDays(7)); + UsageHistoryEntry current = new(Now, 100, Now.AddDays(7)); + Assert.True(WeeklyAllowanceRestoration.IsCycleChange(previous, current)); + } + + [Fact] + public void RetainedHistoryShowsOnlyRealResetEventsAndNoDuplicateRestorationLines() + { + var chart = CreateScenario(); + var svg = Parse(chart); + output.WriteLine("CHART_PREVIEW_SVG=" + svg.ToString(SaveOptions.DisableFormatting)); + + Assert.Equal(2, ResetMarkers(svg).Length); + Assert.Equal(2, svg.Descendants(Svg + "g").Count(group => group.Attribute("data-axis")?.Value == "reset-detected")); + Assert.DoesNotContain(svg.Descendants(Svg + "line"), line => line.Attribute("data-marker")?.Value == "allowance-restored"); + Assert.Equal("56%", Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "current") + .Attribute("data-axis-label")?.Value); + Assert.Contains("across quota resets", chart.AltText, StringComparison.Ordinal); + } + + [Fact] + public void ResetAndNowLabelsFitInsideTheHeaderWithoutOverlapping() + { + var svg = Parse(CreateScenario()); + var labels = svg.Descendants(Svg + "g") + .Where(group => group.Attribute("data-axis")?.Value is "marker" or "reset-detected") + .Select(LabelBounds).ToArray(); + Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "NOW"); + Assert.All(labels, bounds => Assert.InRange(bounds.Top + bounds.Height, 0, PlotTop(svg))); + AssertNonOverlapping(labels); + } + + [Fact] + public void DenseCalendarLabelsKeepEveryDateWithoutOverlapping() + { + var svg = Parse(CreateScenario()); + var dayLabels = svg.Descendants(Svg + "g").Where(group => group.Attribute("data-axis")?.Value == "horizontal").ToArray(); + Assert.Equal(14, dayLabels.Length); + Assert.Equal("Sat 26", dayLabels[0].Attribute("data-axis-label")?.Value); + Assert.Equal("Fri 9", dayLabels[^1].Attribute("data-axis-label")?.Value); + Assert.All(dayLabels, group => Assert.Equal(2, group.Elements(Svg + "g").Count())); + var visibleLabels = dayLabels.SelectMany(group => group.Elements(Svg + "path").Any() + ? new[] { group } : group.Elements(Svg + "g")).Select(LabelBounds).ToArray(); + AssertNonOverlapping(visibleLabels); + } + + [Fact] + public void BillionTokenScaleUsesACompactLabelAtTheTopGridline() + { + var svg = Parse(CreateScenario()); + var topTokenLabel = Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens" + && group.Attribute("data-axis-label")?.Value == "1B"); + Assert.Equal(PlotTop(svg) - ChartLabelGlyphs.Height / 2, LabelBounds(topTokenLabel).Top); + } + + private static WeeklyUsageTrendChart CreateScenario() + { + var firstReset = new DateTimeOffset(2026, 9, 26, 16, 0, 0, TimeSpan.Zero); + var currentStart = new DateTimeOffset(2026, 10, 2, 21, 10, 0, TimeSpan.Zero); + var idle = Enumerable.Range(0, 124).Select(index => + { + var at = firstReset.AddMinutes(index * 20); + return new UsageHistoryEntry(at, 100, at.AddDays(7), 10080); + }).ToArray(); + var oldReset = idle[^1].ResetsAt!.Value; + UsageHistoryEntry[] history = + [ + new(firstReset.AddHours(-2), 1, firstReset.AddHours(3), 10080), + .. idle, + new(idle[^1].RecordedAt.AddMinutes(10), 95, oldReset, 10080), + new(currentStart.AddDays(-2), 70, oldReset, 10080), + new(currentStart.AddDays(-1), 60, oldReset, 10080), + new(currentStart.AddMinutes(-15), 36, oldReset, 10080), + new(currentStart, 100, currentStart.AddDays(7), 10080), + new(Now, 56, currentStart.AddDays(7), 10080), + ]; + var tokens = new LocalTokenUsageSnapshot( + [ + new(new DateOnly(2026, 9, 26), 850_000_000), + new(new DateOnly(2026, 9, 27), 350_000_000), + new(new DateOnly(2026, 10, 1), 250_000_000), + new(new DateOnly(2026, 10, 2), 600_000_000), + new(new DateOnly(2026, 10, 3), 200_000_000), + ], Now, LocalTokenUsageStatus.Complete); + return Create(history, currentStart.AddDays(7), new(new DateTimeOffset(2026, 10, 5, 0, 0, 0, TimeSpan.Zero), 0, true), tokens); + } + + private static WeeklyUsageTrendChart Create(UsageHistoryEntry[] history, DateTimeOffset reset, + UsageTrendForecast? forecast = null, LocalTokenUsageSnapshot? tokens = null) => + Assert.IsType(WeeklyUsageTrendChartRenderer.Create(history, + new RateLimitWindow(44, 10080, reset), Now, TimeSpan.FromMinutes(15), forecast, tokens, + CultureInfo.InvariantCulture, TimeZoneInfo.Utc)); + + private static XDocument Parse(WeeklyUsageTrendChart chart) => + XDocument.Parse(Uri.UnescapeDataString(chart.ImageUrl["data:image/svg+xml;utf8,".Length..])); + + private static XElement[] ObservedLines(XDocument svg) => svg.Descendants(Svg + "polyline") + .Where(line => line.Attribute("stroke-dasharray") is null).ToArray(); + + private static XElement[] ResetMarkers(XDocument svg) => svg.Descendants(Svg + "line") + .Where(line => line.Attribute("data-marker")?.Value == "quota-reset").ToArray(); + + private static double PlotTop(XDocument svg) => double.Parse(svg.Descendants(Svg + "line") + .Single(line => line.Attribute("data-grid")?.Value == "remaining-percent" && line.Attribute("data-value")?.Value == "100") + .Attribute("y1")!.Value, CultureInfo.InvariantCulture); + + private static (double Left, double Top, double Width, double Height) LabelBounds(XElement group) + { + var translate = group.Descendants(Svg + "path").First().Attribute("transform")!.Value["translate(".Length..^1].Split(' '); + var rendered = group.Attribute("data-rendered-label")!.Value; + return (double.Parse(translate[0], CultureInfo.InvariantCulture), double.Parse(translate[1], CultureInfo.InvariantCulture), + rendered.Sum(character => ChartLabelGlyphs.Values[character].Width), ChartLabelGlyphs.Height); + } + + private static void AssertNonOverlapping((double Left, double Top, double Width, double Height)[] labels) + { + for (var first = 0; first < labels.Length; first++) + { + for (var second = first + 1; second < labels.Length; second++) + { + var a = labels[first]; + var b = labels[second]; + Assert.False(a.Left < b.Left + b.Width && a.Left + a.Width > b.Left + && a.Top < b.Top + b.Height && a.Top + a.Height > b.Top, + $"Labels overlap: {a} and {b}."); + } + } + } +} diff --git a/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs b/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs index 95d3044..742dbad 100644 --- a/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs +++ b/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs @@ -65,7 +65,7 @@ public void ResetAndRestartPreserveObservedHistoryAndBootstrapFromLearnedUsage(b var observed = Assert.Single(chart.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is null); Assert.Equal(2, observed.Attribute("points")!.Value.Split(' ').Length); Assert.Single(chart.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null); - Assert.Contains("amber: reset/restoration", data.GetProperty("chartLegend").GetString(), StringComparison.Ordinal); + Assert.Contains("amber: reset/restoration", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); var otherAccount = afterReset with { AccountKey = "other-account" }; restarted.RecordHistory(otherAccount, now); diff --git a/CodexUsageDock/CodexUsageDock.csproj b/CodexUsageDock/CodexUsageDock.csproj index f3e30a6..ff30d51 100644 --- a/CodexUsageDock/CodexUsageDock.csproj +++ b/CodexUsageDock/CodexUsageDock.csproj @@ -3,7 +3,7 @@ WinExe CodexUsageDock app.manifest - 0.8.4 + 0.8.5 10.0.26100.68-preview net10.0-windows10.0.26100.0 diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index 0ab220b..9514012 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -95,12 +95,6 @@ internal static string FormatMainDataJson( data, snapshot.Secondary, weeklyHistory, weeklyTrend, dataAvailable, snapshot.Source is UsageDataSource.AppServer or UsageDataSource.LocalSession or UsageDataSource.LastConfirmed, now, TrendMaximumGap(refreshInterval), tokenUsage); - data["chartLegend"] = tokenUsage?.Status switch - { - LocalTokenUsageStatus.Complete => "Line: remaining % · amber: reset/restoration · bars: local tokens/day (right axis)", - LocalTokenUsageStatus.Partial => "Line: remaining % · amber: reset/restoration · bars: partial local tokens/day (right axis)", - _ => "Line: remaining % · amber: reset/restoration · local tokens unavailable", - }; return data.ToJsonString(); } @@ -438,13 +432,13 @@ private static void AddWeeklyTrendData( data["weeklyTrendChartUrl"] = chart.ImageUrl; data["weeklyTrendChartAlt"] = chart.AltText; var forecastLegend = dataAvailable && trend?.Forecast is not null - ? "dashed: forecast" - : "forecast pending sufficient fresh measurements"; + ? "dashed: estimate" + : "forecast pending"; data["weeklyTrendLegend"] = tokenUsage?.Status switch { - LocalTokenUsageStatus.Complete => $"Solid: remaining quota (%) · breaks: resets/restorations · {forecastLegend} · bars: local tokens per day · amber: detected resets/restorations", - LocalTokenUsageStatus.Partial => $"Solid: remaining quota (%) · breaks: resets/restorations · {forecastLegend} · bars: partial local tokens per day · amber: detected resets/restorations", - _ => $"Solid: remaining quota (%) · breaks: resets/restorations · {forecastLegend} · local token data unavailable · amber: detected resets/restorations", + LocalTokenUsageStatus.Complete => $"Line: remaining % · amber: reset/restoration · bars: local tokens/day (right axis) · {forecastLegend}", + LocalTokenUsageStatus.Partial => $"Line: remaining % · amber: reset/restoration · bars: partial local tokens per day (right axis) · {forecastLegend}", + _ => $"Line: remaining % · amber: reset/restoration · local token data unavailable · {forecastLegend}", }; var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now); diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index 7f8b500..cfd2dab 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -103,7 +103,11 @@ internal static class UsageDashboardCard "size": "stretch", "spacing": "small" }, { - "type": "TextBlock", "text": "${chartLegend} · dashed: estimate", + "type": "TextBlock", "text": "${weeklyTrendLegend}", + "isSubtle": true, "size": "small", "wrap": true, "spacing": "small" + }, + { + "type": "TextBlock", "text": "${weeklyForecastStatus}", "isSubtle": true, "size": "small", "wrap": true, "spacing": "small" } ] diff --git a/CodexUsageDock/WeeklyAllowanceRestoration.cs b/CodexUsageDock/WeeklyAllowanceRestoration.cs index bad9b74..0153cdd 100644 --- a/CodexUsageDock/WeeklyAllowanceRestoration.cs +++ b/CodexUsageDock/WeeklyAllowanceRestoration.cs @@ -70,7 +70,25 @@ internal static bool IsIncrease(UsageHistoryEntry previous, UsageHistoryEntry cu internal static bool IsCycleChange(UsageHistoryEntry previous, UsageHistoryEntry current) => previous.ResetsAt.HasValue && current.ResetsAt.HasValue - && (current.ResetsAt.Value - previous.ResetsAt.Value).Duration() > ResetCycleTolerance; + && (current.ResetsAt.Value - previous.ResetsAt.Value).Duration() > ResetCycleTolerance + && !IsUnusedResetEstimate(previous, current); + + private static bool IsUnusedResetEstimate(UsageHistoryEntry previous, UsageHistoryEntry current) + { + if (previous.RemainingPercent != 100 + || previous.WindowMinutes is not > 0 + || current.WindowMinutes != previous.WindowMinutes) + { + return false; + } + + // Before first consumption, the server can report a deadline one full window from each reading. + // The deadline may settle between readings when usage starts; neither change proves a reset. + var duration = TimeSpan.FromMinutes(previous.WindowMinutes.Value); + return (previous.ResetsAt!.Value - previous.RecordedAt - duration).Duration() <= ResetCycleTolerance + && current.ResetsAt!.Value - previous.RecordedAt - duration >= -ResetCycleTolerance + && current.ResetsAt.Value - current.RecordedAt - duration <= ResetCycleTolerance; + } internal static bool IsInCycle(UsageHistoryEntry sample, DateTimeOffset resetsAt, int windowMinutes) => (!sample.WindowMinutes.HasValue || sample.WindowMinutes.Value == windowMinutes) diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index ec49de1..9b783ee 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -9,15 +9,15 @@ internal sealed record WeeklyUsageTrendChart(string ImageUrl, string AltText); internal static class WeeklyUsageTrendChartRenderer { internal const int Width = 960; - internal const int Height = 240; + internal const int Height = 280; internal const int MaximumRenderedPoints = 180; private const double Left = 58; private const double Right = 58; - private const double TrendTop = 28; - private const double TrendBottom = 207; + private const double TrendTop = 48; + private const double TrendBottom = 227; private const double TrendHeight = TrendBottom - TrendTop; - private const double DayLabelTop = 219; + private const double DayLabelTop = 239; private const string AxisLabelFill = "#D0D0D0"; private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; internal static WeeklyUsageTrendChart? Create( @@ -72,10 +72,10 @@ internal static class WeeklyUsageTrendChartRenderer AddTokenAxis(document, tokenScaleMaximum); AddCalendarDayGrid(document, dailyUse, calendarScale); AddDailyTokenBars(document, dailyUse, calendarScale, displayCulture, tokenScaleMaximum); - AddResetMarkers(document, windowStart, window.ResetsAt, calendarScale); AddNowMarker(document, plotStart, window.ResetsAt, effectiveNow, calendarScale); AddDetectedResetMarkers(document, resets, calendarScale, displayCulture, displayTimeZone); - AddRestorationMarkers(document, restorations, calendarScale); + AddResetMarkers(document, windowStart, window.ResetsAt, calendarScale); + AddRestorationMarkers(document, restorations, resets, calendarScale); AddObservedLines(document, renderedSegments, calendarScale); AddForecastLine(document, forecastSegment, usableForecast, window.ResetsAt, now, calendarScale, displayCulture, displayTimeZone); @@ -374,8 +374,7 @@ private static void AddTokenAxis(XElement document, long tokenScaleMaximum) foreach (var (tokens, y) in new[] { - // Keep the top token label below the reset label at the chart boundary. - (tokenScaleMaximum, TrendTop + ChartLabelHeight + 2), + (tokenScaleMaximum, TrendTop), (tokenScaleMaximum / 2d, TrendTop + TrendHeight / 2), }) { @@ -395,7 +394,8 @@ private static void AddChartLabel( double anchorX, double top, ChartLabelAlignment alignment, - string axis) + string axis, + string? fill = null) { var rendered = NormalizeChartLabel(label); var width = MeasureChartLabel(rendered); @@ -409,7 +409,10 @@ private static void AddChartLabel( Svg + "g", new XAttribute("data-axis", axis), new XAttribute("data-axis-label", label), - new XAttribute("data-rendered-label", rendered)); + new XAttribute("data-rendered-label", rendered), + new XAttribute("data-label-left", Format(left)), + new XAttribute("data-label-top", Format(top)), + new XAttribute("data-label-width", Format(width))); var glyphLeft = left; foreach (var character in rendered) @@ -421,7 +424,7 @@ private static void AddChartLabel( Svg + "path", new XAttribute("d", glyph.Path), new XAttribute("transform", $"translate({Format(glyphLeft)} {Format(top)})"), - new XAttribute("fill", AxisLabelFill))); + new XAttribute("fill", fill ?? AxisLabelFill))); } glyphLeft += glyph.Width; } @@ -429,6 +432,33 @@ private static void AddChartLabel( document.Add(group); } + private static void AddMarkerLabel(XElement document, string label, double x, string axis) + { + 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 }) + { + var overlaps = document.Elements(Svg + "g") + .Where(group => group.Attribute("data-axis")?.Value is "marker" or "reset-detected") + .Any(group => + { + var otherLeft = double.Parse(group.Attribute("data-label-left")!.Value, CultureInfo.InvariantCulture); + var otherTop = double.Parse(group.Attribute("data-label-top")!.Value, CultureInfo.InvariantCulture); + var otherWidth = double.Parse(group.Attribute("data-label-width")!.Value, CultureInfo.InvariantCulture); + return left < otherLeft + otherWidth + 6 && left + width + 6 > otherLeft + && top < otherTop + ChartLabelHeight && top + ChartLabelHeight > otherTop; + }); + if (!overlaps) + { + AddChartLabel(document, label, center, top, ChartLabelAlignment.Center, axis, + axis == "reset-detected" ? "#F2C94C" : null); + return; + } + } + // Keep the event line and its accessible description when both header rows are occupied. + } + private static string NormalizeChartLabel(string label) { var normalized = new StringBuilder(label.Length); @@ -506,7 +536,7 @@ private static void AddResetMarkers( new XAttribute("stroke-width", "1"), new XAttribute("stroke-dasharray", "2 2"), new XAttribute("data-marker", $"reset-{edge}"))); - AddChartLabel(document, "RESET", x, 0, ChartLabelAlignment.Center, "marker"); + AddMarkerLabel(document, edge == "start" ? "START" : "RESET", x, "marker"); } } @@ -534,22 +564,23 @@ private static void AddNowMarker( new XAttribute("stroke-opacity", "0.45"), new XAttribute("stroke-width", "1"), new XAttribute("data-marker", "now"))); - var labelWidth = MeasureChartLabel("NOW"); - var labelX = Math.Clamp(x, Left + labelWidth / 2, Width - Right - labelWidth / 2); - AddChartLabel(document, "NOW", labelX, 0, ChartLabelAlignment.Center, "marker"); + AddMarkerLabel(document, "NOW", x, "marker"); } private static void AddRestorationMarkers( XElement document, AllowanceRestoration[] restorations, + QuotaCycleChange[] resets, CalendarDayScale calendarScale) { + var resetTimes = resets.Select(reset => reset.DetectedAt).ToHashSet(); foreach (var restoration in restorations) { var x = calendarScale.GetX(restoration.DetectedAt); var y = GetTrendY(restoration.CurrentRemainingPercent); - document.Add( - new XElement( + if (!resetTimes.Contains(restoration.DetectedAt)) + { + document.Add(new XElement( Svg + "line", new XAttribute("x1", Format(x)), new XAttribute("x2", Format(x)), @@ -560,8 +591,9 @@ private static void AddRestorationMarkers( new XAttribute("stroke-width", "1.5"), new XAttribute("stroke-dasharray", "3 2"), new XAttribute("data-marker", "allowance-restored"), - new XAttribute("data-detected-at", restoration.DetectedAt.ToString("O", CultureInfo.InvariantCulture))), - new XElement( + new XAttribute("data-detected-at", restoration.DetectedAt.ToString("O", CultureInfo.InvariantCulture)))); + } + document.Add(new XElement( Svg + "circle", new XAttribute("cx", Format(x)), new XAttribute("cy", Format(y)), @@ -599,8 +631,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))); - var labelX = Math.Clamp(x, Left + 22, Width - Right - 22); - AddChartLabel(document, "RESET", labelX, TrendTop + 3, ChartLabelAlignment.Center, "reset-detected"); + AddMarkerLabel(document, "RESET", x, "reset-detected"); } } @@ -723,6 +754,9 @@ private static void AddDailyTokenBars( long tokenScaleMaximum) { var baseline = TrendBottom; + var labels = dailyUse.Select(day => FormatCalendarDayLabel(day.Date, culture)).ToArray(); + var columnWidth = calendarScale.GetDayEndX(0) - calendarScale.GetDayStartX(0); + var useTwoLineLabels = labels.Any(label => MeasureChartLabel(NormalizeChartLabel(label)) > columnWidth - 8); for (var index = 0; index < dailyUse.Count; index++) { @@ -754,13 +788,22 @@ private static void AddDailyTokenBars( new XAttribute("stroke-width", day.IsCurrent ? "1" : "0"))); } - AddChartLabel( - document, - FormatCalendarDayLabel(day.Date, culture), - (dayLeft + dayRight) / 2, - DayLabelTop, - ChartLabelAlignment.Center, - "horizontal"); + var label = labels[index]; + var center = (dayLeft + dayRight) / 2; + if (!useTwoLineLabels) + { + AddChartLabel(document, label, center, DayLabelTop, ChartLabelAlignment.Center, "horizontal"); + } + else + { + var group = new XElement(Svg + "g", new XAttribute("data-axis", "horizontal"), + new XAttribute("data-axis-label", label), new XAttribute("data-rendered-label", NormalizeChartLabel(label))); + var separator = label.LastIndexOf(' '); + AddChartLabel(group, label[..separator], center, DayLabelTop, ChartLabelAlignment.Center, "weekday"); + AddChartLabel(group, label[(separator + 1)..], center, DayLabelTop + ChartLabelHeight + 2, + ChartLabelAlignment.Center, "day-of-month"); + document.Add(group); + } } } @@ -825,6 +868,11 @@ private static string FormatDailyTokenAltText( private static string FormatCompactTokens(double tokens) { + if (tokens >= 1_000_000_000) + { + return $"{(tokens / 1_000_000_000).ToString("0.#", CultureInfo.InvariantCulture)}B"; + } + if (tokens >= 1_000_000) { return $"{(tokens / 1_000_000).ToString("0.#", CultureInfo.InvariantCulture)}M"; diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9f211c5..cf52f72 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -64,7 +64,7 @@ The chart uses baked Segoe UI outlines because the Windows SVG image renderer do ## Forecast evaluation -Run `WeeklyForecastTests` for observation-quality gates, historical forecasts after resets or gaps, idle periods, shifted resets, partial coverage, and chronological synthetic forecasts at six hours, 24 hours, and reset. The rolling benchmark uses only completed preceding weeks and data available at each origin, compares mean absolute error in percentage points with a current-pace baseline, and prints both errors. The historical bootstrap benchmark checks repeated patterns from week starts using only preceding completed cycles. These repeated-pattern benchmarks do not establish accuracy on real users or unexpected workload changes. Calibration on representative real histories is required before adding probabilistic intervals or making general accuracy claims. Existing short-window arithmetic fixtures stay short; weekly recent-pace fixtures must satisfy the observation duration requirements, while historical bootstrap fixtures must satisfy the historical coverage requirements. `WeeklyChartResetTests` and `WeeklyResetIntegrationTests` cover retained chart observations, reset markers, persistence after reset and restart, and account isolation. +Run `WeeklyForecastTests` for observation-quality gates, historical forecasts after resets or gaps, idle periods, shifted resets, partial coverage, and chronological synthetic forecasts at six hours, 24 hours, and reset. The rolling benchmark uses only completed preceding weeks and data available at each origin, compares mean absolute error in percentage points with a current-pace baseline, and prints both errors. The historical bootstrap benchmark checks repeated patterns from week starts using only preceding completed cycles. These repeated-pattern benchmarks do not establish accuracy on real users or unexpected workload changes. Calibration on representative real histories is required before adding probabilistic intervals or making general accuracy claims. Existing short-window arithmetic fixtures stay short; weekly recent-pace fixtures must satisfy the observation duration requirements, while historical bootstrap fixtures must satisfy the historical coverage requirements. `WeeklyChartResetTests` and `WeeklyResetIntegrationTests` cover retained chart observations, reset markers, persistence after reset and restart, and account isolation. `WeeklyChartReadabilityTests` distinguish moving unused reset estimates from real cycle changes, check label overlap and token-axis alignment, and emit a synthetic chart SVG for visual inspection. ## Integration smoke test diff --git a/README.md b/README.md index c710780..5372cc6 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Dock-band and dashboard percentages show **remaining allowance**. Codex and addi Values refresh every minute by default. Open **Usage information** from the dashboard's **More** menu for the data source, measurement time, reset-credit expirations, credits balance, and explanatory text. During an outage, **Last confirmed** retains the previous observation with its age and pauses time comparisons and forecasts. Local fallback data remains labeled. -The weekly forecast status 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. 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 and daily budget are 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 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**. See the [user guide](USER_GUIDE.md#charts-and-forecasts) for chart legends, adaptive forecasts, and data handling. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0261990..7cbc8e4 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -10,7 +10,9 @@ The dashboard groups Codex and additional quota categories such as Spark into th 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. -Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. Local daily token bars and their independent right-hand scale appear by default when available; a short legend distinguishes the two axes and identifies partial data. The weekly forecast status 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. +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. 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. From 5d8cf4e5410a4dbf51f5ac487818ee46b7270969 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:13:32 +0200 Subject: [PATCH 2/2] Link 0.8.5 changelog to implementation --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3c1c8..0d7e5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ### Fixed -- Avoid false reset markers and broken history lines when an unused quota's estimated reset time moves with each reading. Keep real reset events, remove duplicate restoration lines, and prevent chart labels from overlapping. Show compact billion-token labels and the forecast basis beside the chart, with a legend that reflects the available data. +- Avoid false reset markers and broken history lines when an unused quota's estimated reset time moves with each reading. Keep real reset events, remove duplicate restoration lines, and prevent chart labels from overlapping. Show compact billion-token labels and the forecast basis beside the chart, with a legend that reflects the available data. ([commit e9baaa7](https://github.com/TheBeems/CodexUsageDock/commit/e9baaa73ac545e8d1855cddb774c75c544a458b5)) ## [0.8.4] - 2026-10-03 @@ -248,7 +248,8 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). - Initial release of the Windows Command Palette extension for viewing local Codex usage. [commit ac72fe5](https://github.com/TheBeems/CodexUsageDock/commit/ac72fe50fcd1af36f41cda896f1d792899573351) - Automated release installer creation and smoke-test handling. [commit 64a3305](https://github.com/TheBeems/CodexUsageDock/commit/64a33058b7486dea12f026561c545b362eb2d622), [commit 21790a1](https://github.com/TheBeems/CodexUsageDock/commit/21790a1ea60a9bfec14ec578d77356c5576472fb) -[Unreleased]: https://github.com/TheBeems/CodexUsageDock/compare/v0.8.4...main +[Unreleased]: https://github.com/TheBeems/CodexUsageDock/compare/e9baaa73ac545e8d1855cddb774c75c544a458b5...main +[0.8.5]: https://github.com/TheBeems/CodexUsageDock/compare/v0.8.4...e9baaa73ac545e8d1855cddb774c75c544a458b5 [0.8.4]: https://github.com/TheBeems/CodexUsageDock/releases/tag/v0.8.4 [0.8.3]: https://github.com/TheBeems/CodexUsageDock/compare/46e4a985ce288ff6a3cbc1ada64471716598850b...ef7046cdd5a1478b452c29cfd0c6e2fd94134ab9 [0.8.2]: https://github.com/TheBeems/CodexUsageDock/compare/4fe435e6a03a790cffe5b03384ff4f2436e206d3...46e4a985ce288ff6a3cbc1ada64471716598850b