From b78eb22c25bd0ba8dc1e4d0fbe545e836fa74b1b Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:53:56 +0200 Subject: [PATCH 1/2] Add labeled weekly usage trend --- CHANGELOG.md | 8 + CodexUsageDock.Tests/UsageDataTests.cs | 201 ++++++- CodexUsageDock/Pages/CodexUsageDockPage.cs | 76 ++- CodexUsageDock/Pages/UsageDashboardCard.cs | 32 ++ CodexUsageDock/UsageData.cs | 5 + CodexUsageDock/WeeklyUsageTrendChart.cs | 618 +++++++++++++++++++++ DEVELOPMENT.md | 2 +- README.md | 1 + 8 files changed, 928 insertions(+), 15 deletions(-) create mode 100644 CodexUsageDock/WeeklyUsageTrendChart.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index aa662dc..d11898e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Each entry links to the commit or pull request that introduced the change. ## [Unreleased] +### Added + +- The weekly usage dashboard now combines observed remaining allowance, a dashed reset projection, and locally observed daily quota consumption in one seven-day trend chart. + +### Changed + +- The weekly trend chart now labels its 0–100% vertical scale and localized quota-window weekdays without relying on unsupported SVG text. + ## [0.5.2] - 2026-07-16 ### Fixed diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 40fa5e4..f665b47 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Text.Json; +using System.Xml.Linq; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Xunit; @@ -7,6 +9,14 @@ namespace CodexUsageDock.Tests; public sealed class UsageDataTests { private static readonly TimeSpan AsyncTestTimeout = TimeSpan.FromSeconds(5); + private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; + + private static XDocument ParseSvg(string imageUrl) + { + const string Prefix = "data:image/svg+xml;utf8,"; + Assert.StartsWith(Prefix, imageUrl, StringComparison.Ordinal); + return XDocument.Parse(Uri.UnescapeDataString(imageUrl[Prefix.Length..])); + } [Fact] public void SettingsDefaultToShowingAllDockUsageInformation() @@ -195,6 +205,8 @@ public void DetailsPageUsesNativeMediumDetailsPane() Assert.Contains("\"type\": \"Image\"", main.TemplateJson, StringComparison.Ordinal); Assert.Contains("Allowance used", main.TemplateJson, StringComparison.Ordinal); Assert.Contains("Window elapsed", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("weeklyTrendAvailable", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("Solid: remaining allowance", main.TemplateJson, StringComparison.Ordinal); Assert.Same(Assert.Single(page.GetContent()), Assert.Single(page.GetContent())); } @@ -218,7 +230,11 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), now, isLoading: false, [new UsageHistoryEntry(now.AddMinutes(-30), 90), new UsageHistoryEntry(now, 80)], - [new UsageHistoryEntry(now.AddHours(-12), 99), new UsageHistoryEntry(now, 98)], + [ + new UsageHistoryEntry(now.AddHours(-12), 99), + new UsageHistoryEntry(now.AddMinutes(-10), 98.01), + new UsageHistoryEntry(now, 98), + ], TimeSpan.FromMinutes(1)); var details = CodexUsageDockPage.FormatDetailsBody(snapshot, now); using var mainData = JsonDocument.Parse(main); @@ -235,6 +251,9 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("fiveHourElapsedBarUrl").GetString(), StringComparison.Ordinal); Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyUsedBarUrl").GetString(), StringComparison.Ordinal); Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyElapsedBarUrl").GetString(), StringComparison.Ordinal); + Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); + Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyTrendChartUrl").GetString(), StringComparison.Ordinal); + Assert.Contains("Solid line and points are observed", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); Assert.Equal("On track", root.GetProperty("fiveHourPaceStatus").GetString()); Assert.Equal("Comfortably on track", root.GetProperty("weeklyPaceStatus").GetString()); Assert.Contains("Projected at reset", root.GetProperty("fiveHourProjection").GetString(), StringComparison.Ordinal); @@ -321,6 +340,186 @@ public void UsageProgressBarHandlesNonFinitePercentage() Assert.DoesNotContain("NaN", bar, StringComparison.Ordinal); } + [Fact] + public void WeeklyTrendChartRendersObservedForecastAndDailyUse() + { + var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = windowStart.AddDays(2).AddHours(6); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(windowStart.AddMinutes(10), 100), + new UsageHistoryEntry(windowStart.AddHours(4), 95), + new UsageHistoryEntry(windowStart.AddDays(1).AddHours(4), 88), + new UsageHistoryEntry(now, 80), + ], + new RateLimitWindow(20, 10080, reset), + now, + TimeSpan.FromDays(2), + new UsageTrendForecast(reset, 60, false)); + + var result = Assert.IsType(chart); + var svg = ParseSvg(result.ImageUrl); + var root = svg.Root; + Assert.NotNull(root); + var lines = root!.Descendants(Svg + "polyline").ToArray(); + + Assert.Equal($"0 0 {UsageDashboardCard.BarWidth} {WeeklyUsageTrendChartRenderer.Height}", root.Attribute("viewBox")?.Value); + Assert.Contains(lines, line => line.Attribute("stroke-dasharray") is null); + Assert.Contains(lines, line => line.Attribute("stroke-dasharray")?.Value == "5 4"); + Assert.True(root.Descendants(Svg + "rect").Count(rect => rect.Attribute("data-series")?.Value == "daily-use") >= 2); + Assert.Contains("Solid line and points are observed", result.AltText, StringComparison.Ordinal); + Assert.DoesNotContain("NaN", result.ImageUrl, StringComparison.Ordinal); + Assert.DoesNotContain("Infinity", result.ImageUrl, StringComparison.Ordinal); + } + + [Fact] + public void WeeklyTrendChartBreaksGapsAndExcludesUnobservedDailyUse() + { + var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = windowStart.AddDays(1).AddHours(1); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(windowStart.AddMinutes(1), 100), + new UsageHistoryEntry(windowStart.AddMinutes(5), 90), + new UsageHistoryEntry(windowStart.AddDays(1).AddMinutes(1), 60), + new UsageHistoryEntry(windowStart.AddDays(1).AddMinutes(5), 50), + ], + new RateLimitWindow(50, 10080, reset), + now, + TimeSpan.FromMinutes(15), + new UsageTrendForecast(reset, 0, false)); + + var result = Assert.IsType(chart); + var svg = ParseSvg(result.ImageUrl); + var root = svg.Root; + Assert.NotNull(root); + var observedLines = root!.Descendants(Svg + "polyline") + .Where(line => line.Attribute("stroke-dasharray") is null) + .ToArray(); + var observedBarHeight = root.Descendants(Svg + "rect") + .Where(bar => bar.Attribute("data-series")?.Value == "daily-use") + .Sum(bar => double.Parse(bar.Attribute("height")!.Value, System.Globalization.CultureInfo.InvariantCulture)); + + Assert.Equal(2, observedLines.Length); + Assert.InRange(observedBarHeight, 5.3, 5.5); + } + + [Fact] + public void WeeklyTrendChartRendersHostSafeLocalizedAxisLabels() + { + var culture = CultureInfo.GetCultureInfo("nl-NL"); + var windowStart = new DateTimeOffset(2026, 7, 13, 9, 0, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = windowStart.AddDays(1); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(windowStart.AddMinutes(1), 100), + new UsageHistoryEntry(now, 90), + ], + new RateLimitWindow(10, 10080, reset), + now, + TimeSpan.FromDays(2), + forecast: null, + culture: culture); + + var result = Assert.IsType(chart); + var svg = ParseSvg(result.ImageUrl); + var root = Assert.IsType(svg.Root); + var verticalLabels = root.Descendants(Svg + "g") + .Where(group => group.Attribute("data-axis")?.Value == "vertical") + .Select(group => group.Attribute("data-axis-label")!.Value) + .ToArray(); + var horizontalLabels = root.Descendants(Svg + "g") + .Where(group => group.Attribute("data-axis")?.Value == "horizontal") + .Select(group => group.Attribute("data-axis-label")!.Value) + .ToArray(); + + Assert.Equal(["100%", "50%", "0%"], verticalLabels); + Assert.Equal(["ma", "di", "wo", "do", "vr", "za", "zo"], horizontalLabels); + Assert.Equal(["MA", "DI", "WO", "DO", "VR", "ZA", "ZO"], root.Descendants(Svg + "g") + .Where(group => group.Attribute("data-axis")?.Value == "horizontal") + .Select(group => group.Attribute("data-rendered-label")!.Value)); + Assert.Empty(root.Descendants(Svg + "text")); + Assert.All(root.Descendants(Svg + "g").Where(group => group.Attribute("data-axis") is not null), group => + Assert.NotEmpty(group.Descendants(Svg + "rect"))); + Assert.Contains("vertical scale is remaining allowance", result.AltText, StringComparison.Ordinal); + } + + [Fact] + public void WeeklyTrendChartKeepsIsolatedObservationsAsPoints() + { + var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = windowStart.AddDays(1); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(windowStart.AddMinutes(1), 100), + new UsageHistoryEntry(now, 80), + ], + new RateLimitWindow(20, 10080, reset), + now, + TimeSpan.FromMinutes(15), + forecast: null); + + var result = Assert.IsType(chart); + var svg = ParseSvg(result.ImageUrl); + + Assert.Empty(svg.Descendants(Svg + "polyline")); + Assert.Equal(2, svg.Descendants(Svg + "circle").Count()); + } + + [Fact] + public void WeeklyTrendChartOmitsForecastWithoutAProjection() + { + var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = windowStart.AddHours(1); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(windowStart.AddMinutes(1), 100), + new UsageHistoryEntry(now, 95), + ], + new RateLimitWindow(5, 10080, reset), + now, + TimeSpan.FromMinutes(15), + forecast: null); + + var result = Assert.IsType(chart); + var svg = ParseSvg(result.ImageUrl); + + Assert.DoesNotContain(svg.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null); + Assert.Contains("Forecast is unavailable", result.AltText, StringComparison.Ordinal); + } + + [Fact] + public void WeeklyTrendChartBoundsMinuteHistory() + { + var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = reset.AddMinutes(-1); + var samples = Enumerable.Range(0, 10080) + .Select(index => new UsageHistoryEntry( + windowStart.AddMinutes(index), + 100 - index * 50d / 10079d)) + .ToArray(); + var chart = WeeklyUsageTrendChartRenderer.Create( + samples, + new RateLimitWindow(50, 10080, reset), + now, + TimeSpan.FromMinutes(15), + forecast: null); + + var result = Assert.IsType(chart); + var svg = ParseSvg(result.ImageUrl); + var observed = Assert.Single(svg.Descendants(Svg + "polyline")); + var points = observed.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + Assert.InRange(points.Length, 2, WeeklyUsageTrendChartRenderer.MaximumRenderedPoints); + Assert.True(result.ImageUrl.Length < 50000); + } + [Fact] public void UsagePaceWarnsWhenAllowanceRunsFarAheadOfElapsedTime() { diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index 2a71d65..a0c03fc 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -66,7 +66,7 @@ internal static string FormatMainDataJson( ["statusDescription"] = statusDescription, }; - AddWindowData( + _ = AddWindowData( data, "fiveHour", "5-hour window", @@ -77,7 +77,7 @@ internal static string FormatMainDataJson( maximumSampleAge, UsageBarPalette.FiveHour, snapshot.Secondary is not null ? "Currently inactive" : "Not available"); - AddWindowData( + var weeklyTrend = AddWindowData( data, "weekly", "Weekly window", @@ -88,6 +88,7 @@ internal static string FormatMainDataJson( maximumSampleAge, UsageBarPalette.Weekly, "Not available"); + AddWeeklyTrendData(data, snapshot.Secondary, weeklyTrend, now, TrendMaximumGap(refreshInterval)); return data.ToJsonString(); } @@ -188,7 +189,7 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi return $"## {name}\n\n**{window.RemainingPercent:0}% available** \nResets {FormatRelativeTime(window.ResetsAt, now)} · {FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}"; } - private static void AddWindowData( + private static TrendAnalysis? AddWindowData( JsonObject data, string prefix, string windowName, @@ -204,7 +205,7 @@ private static void AddWindowData( if (window is null) { data[$"{prefix}State"] = inactiveMessage; - return; + return null; } data[$"{prefix}Remaining"] = $"{window.RemainingPercent:0}%"; @@ -226,13 +227,44 @@ private static void AddWindowData( data[$"{prefix}PaceColor"] = paceColor; var currentHistory = GetCurrentTrendHistory(history, windowStartsAt); - data[$"{prefix}Projection"] = AnalyzeTrend( + var trend = AnalyzeTrend( currentHistory, null, window.ResetsAt, now, dataAvailable, - maximumSampleAge).Message; + maximumSampleAge); + data[$"{prefix}Projection"] = trend.Message; + return trend; + } + + private static void AddWeeklyTrendData( + JsonObject data, + RateLimitWindow? window, + TrendAnalysis? trend, + DateTimeOffset now, + TimeSpan maximumGap) + { + data["weeklyTrendAvailable"] = false; + if (window is null || trend is null || trend.HistoryValues is null || trend.History.Length < 2) + { + return; + } + + var chart = WeeklyUsageTrendChartRenderer.Create( + trend.History, + window, + now, + maximumGap, + trend.Forecast); + if (chart is null) + { + return; + } + + data["weeklyTrendAvailable"] = true; + data["weeklyTrendChartUrl"] = chart.ImageUrl; + data["weeklyTrendChartAlt"] = chart.AltText; } private static (string Status, string Color) FormatPaceStatus(double usedPercent, double elapsedPercent) @@ -346,13 +378,13 @@ private static TrendAnalysis AnalyzeTrend( { if (!dataAvailable) { - return new(null, "Projection unavailable until fresh usage data is loaded.", false); + return new([], null, "Projection unavailable until fresh usage data is loaded.", false, null); } var currentWindow = GetCurrentTrendHistory(history, windowStartsAt); if (currentWindow.Length < 2) { - return new(null, "Projection will appear after another measurement.", false); + return new(currentWindow, null, "Projection will appear after another measurement.", false, null); } var samples = currentWindow.Length <= 5 ? currentWindow : currentWindow.Where((_, index) => index % Math.Max(1, currentWindow.Length / 4) == 0).Take(4).Append(currentWindow[^1]).ToArray(); @@ -363,12 +395,12 @@ private static TrendAnalysis AnalyzeTrend( var consumed = first.RemainingPercent - last.RemainingPercent; if (elapsedMinutes < 2 || consumed <= 0.5) { - return new(values, "No meaningful change yet; projection pending.", false); + return new(currentWindow, values, "No meaningful change yet; projection pending.", false, null); } if (now - last.RecordedAt > maximumSampleAge) { - return new(values, "Projection paused because the latest measurement is too old.", false); + return new(currentWindow, values, "Projection paused because the latest measurement is too old.", false, null); } var minutesToEmpty = last.RemainingPercent / (consumed / elapsedMinutes); @@ -376,13 +408,28 @@ private static TrendAnalysis AnalyzeTrend( if (resetsAt is { } reset && estimated >= reset) { var remainingAtReset = Math.Max(0, last.RemainingPercent - (consumed / elapsedMinutes) * (reset - last.RecordedAt).TotalMinutes); - return new(values, $"Projected at reset: {remainingAtReset:0}% available.", true); + return new( + currentWindow, + values, + $"Projected at reset: {remainingAtReset:0}% available.", + true, + new UsageTrendForecast(reset, remainingAtReset, false)); } - return new(values, $"At the current rate, the limit may be reached around {FormatLimitEstimate(estimated, now)}.", true); + return new( + currentWindow, + values, + $"At the current rate, the limit may be reached around {FormatLimitEstimate(estimated, now)}.", + true, + new UsageTrendForecast(estimated, 0, true)); } - private sealed record TrendAnalysis(string? HistoryValues, string Message, bool IsEstimate); + private sealed record TrendAnalysis( + UsageHistoryEntry[] History, + string? HistoryValues, + string Message, + bool IsEstimate, + UsageTrendForecast? Forecast); private static UsageHistoryEntry[] GetCurrentTrendHistory( IReadOnlyList history, @@ -411,6 +458,9 @@ private static string FormatLimitEstimate(DateTimeOffset estimated, DateTimeOffs private static TimeSpan TrendFreshness(TimeSpan refreshInterval) => refreshInterval > TimeSpan.FromMinutes(5) ? refreshInterval : TimeSpan.FromMinutes(5); + private static TimeSpan TrendMaximumGap(TimeSpan refreshInterval) => + TimeSpan.FromTicks(TrendFreshness(refreshInterval).Ticks * 3); + internal static string FormatResetSummary(RateLimitResetCredits? resets, DateTimeOffset now) { if (resets is null) return "- **Resets:** not available"; diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index 5636f42..a51700c 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -350,6 +350,33 @@ internal static class UsageDashboardCard "spacing": "small", "wrap": true, "$when": "${weeklyAvailable}" + }, + { + "type": "Container", + "separator": true, + "spacing": "medium", + "$when": "${weeklyTrendAvailable}", + "items": [ + { + "type": "TextBlock", + "text": "Weekly trend", + "weight": "bolder" + }, + { + "type": "TextBlock", + "text": "Solid: remaining allowance (%) · dashed: forecast · bars: observed daily use (%)", + "isSubtle": true, + "spacing": "none", + "wrap": true + }, + { + "type": "Image", + "url": "${weeklyTrendChartUrl}", + "altText": "${weeklyTrendChartAlt}", + "size": "stretch", + "spacing": "small" + } + ] } ] } @@ -401,6 +428,11 @@ internal static string CreateProgressBarImageUrl(double percent, UsageBarPalette new XAttribute("fill", fillColor))); } + return CreateSvgImageUrl(document); + } + + internal static string CreateSvgImageUrl(XElement document) + { var encodedSvg = Uri.EscapeDataString(document.ToString(SaveOptions.DisableFormatting)); return $"data:image/svg+xml;utf8,{encodedSvg}"; } diff --git a/CodexUsageDock/UsageData.cs b/CodexUsageDock/UsageData.cs index 2b21009..9acc19d 100644 --- a/CodexUsageDock/UsageData.cs +++ b/CodexUsageDock/UsageData.cs @@ -72,6 +72,11 @@ internal sealed record RateLimitResetCredits(int AvailableCount, IReadOnlyList BitmapGlyphs = new() + { + ['0'] = "111101101101111", + ['1'] = "010110010010111", + ['2'] = "111001111100111", + ['3'] = "111001111001111", + ['4'] = "101101111001001", + ['5'] = "111100111001111", + ['6'] = "111100111101111", + ['7'] = "111001010010010", + ['8'] = "111101111101111", + ['9'] = "111101111001111", + ['A'] = "010101111101101", + ['B'] = "110101110101110", + ['C'] = "111100100100111", + ['D'] = "110101101101110", + ['E'] = "111100110100111", + ['F'] = "111100110100100", + ['G'] = "111100101101111", + ['H'] = "101101111101101", + ['I'] = "111010010010111", + ['J'] = "001001001101111", + ['K'] = "101101110101101", + ['L'] = "100100100100111", + ['M'] = "101111111101101", + ['N'] = "101111111111101", + ['O'] = "111101101101111", + ['P'] = "111101111100100", + ['Q'] = "111101101111001", + ['R'] = "110101110101101", + ['S'] = "111100111001111", + ['T'] = "111010010010010", + ['U'] = "101101101101111", + ['V'] = "101101101101010", + ['W'] = "101101111111101", + ['X'] = "101101010101101", + ['Y'] = "101101010010010", + ['Z'] = "111001010100111", + ['%'] = "101001010100101", + ['?'] = "111001010000010", + }; + + internal static WeeklyUsageTrendChart? Create( + IReadOnlyList history, + RateLimitWindow window, + DateTimeOffset now, + TimeSpan maximumGap, + UsageTrendForecast? forecast, + CultureInfo? culture = null) + { + if (window.WindowMinutes <= 0) + { + return null; + } + + var windowStart = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); + var effectiveNow = now < window.ResetsAt ? now : window.ResetsAt; + if (effectiveNow <= windowStart) + { + return null; + } + + var displayCulture = culture ?? CultureInfo.CurrentCulture; + var samples = Normalize(history, windowStart, effectiveNow); + if (samples.Length < 2) + { + return null; + } + + var observedSegments = DownsampleSegments(SplitAtGaps(samples, maximumGap), windowStart, window.ResetsAt); + var dailyUse = CalculateDailyUse(samples, windowStart, window.ResetsAt, effectiveNow, maximumGap); + var latestSegment = observedSegments.LastOrDefault(segment => segment.Length >= 2); + var usableForecast = latestSegment is not null && forecast is { } candidate && candidate.EndsAt > latestSegment[^1].RecordedAt + ? candidate + : null; + + var document = CreateDocument(); + AddTrendGrid(document); + AddNowMarker(document, windowStart, window.ResetsAt, effectiveNow); + AddObservedLines(document, observedSegments, windowStart, window.ResetsAt); + AddForecastLine(document, latestSegment, usableForecast, windowStart, window.ResetsAt); + AddDailyUseBars(document, dailyUse, displayCulture); + + var first = samples[0]; + var last = samples[^1]; + var altText = FormatAltText(first, last, samples.Length, usableForecast, windowStart, window.ResetsAt, dailyUse, displayCulture); + return new WeeklyUsageTrendChart(UsageDashboardCard.CreateSvgImageUrl(document), altText); + } + + private static XElement CreateDocument() => new( + Svg + "svg", + new XAttribute("width", UsageDashboardCard.BarWidth), + new XAttribute("height", Height), + new XAttribute("viewBox", $"0 0 {UsageDashboardCard.BarWidth} {Height}"), + new XAttribute("role", "img")); + + private static UsageHistoryEntry[] Normalize( + IReadOnlyList history, + DateTimeOffset windowStart, + DateTimeOffset now) => + history + .Where(sample => sample.RecordedAt >= windowStart + && sample.RecordedAt <= now + && double.IsFinite(sample.RemainingPercent) + && sample.RemainingPercent is >= 0 and <= 100) + .OrderBy(sample => sample.RecordedAt) + .GroupBy(sample => sample.RecordedAt) + .Select(group => group.Last()) + .ToArray(); + + private static List DownsampleSegments( + List segments, + DateTimeOffset windowStart, + DateTimeOffset windowEnd) + { + var totalSamples = segments.Sum(segment => segment.Length); + if (totalSamples <= MaximumRenderedPoints) + { + return [.. segments]; + } + + var minimumPoints = segments.Select(segment => segment.Length > 1 ? 2 : 1).ToArray(); + if (minimumPoints.Sum() > MaximumRenderedPoints) + { + return Enumerable.Range(0, MaximumRenderedPoints) + .Select(slot => + { + var segment = segments[(int)(slot * segments.Count / (double)MaximumRenderedPoints)]; + return new[] { segment[^1] }; + }) + .ToList(); + } + + var result = new List(segments.Count); + var remainingSamples = totalSamples; + var remainingPoints = MaximumRenderedPoints; + for (var index = 0; index < segments.Count; index++) + { + var segment = segments[index]; + var minimum = minimumPoints[index]; + var laterMinimums = minimumPoints[(index + 1)..].Sum(); + var proportional = (int)Math.Ceiling((double)segment.Length / remainingSamples * remainingPoints); + var maximumForSegment = Math.Min(segment.Length, remainingPoints - laterMinimums); + var selectedPoints = Math.Clamp(proportional, minimum, maximumForSegment); + result.Add(Downsample(segment, windowStart, windowEnd, selectedPoints)); + remainingSamples -= segment.Length; + remainingPoints -= selectedPoints; + } + + return result; + } + + private static UsageHistoryEntry[] Downsample( + UsageHistoryEntry[] samples, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + int maximumPoints) + { + if (samples.Length <= maximumPoints) + { + return samples.ToArray(); + } + + var duration = windowEnd - windowStart; + if (duration <= TimeSpan.Zero) + { + return [samples[0], samples[^1]]; + } + + var selected = new Dictionary(); + foreach (var sample in samples) + { + var position = Math.Clamp((sample.RecordedAt - windowStart).TotalMilliseconds / duration.TotalMilliseconds, 0, 0.999999d); + var bucket = (int)(position * Math.Max(1, maximumPoints - 2)); + selected[bucket] = sample; + } + + var points = new List { samples[0] }; + points.AddRange(selected.OrderBy(pair => pair.Key).Select(pair => pair.Value)); + points.Add(samples[^1]); + return points + .DistinctBy(sample => sample.RecordedAt) + .OrderBy(sample => sample.RecordedAt) + .ToArray(); + } + + private static List SplitAtGaps( + UsageHistoryEntry[] samples, + TimeSpan maximumGap) + { + var gap = maximumGap > TimeSpan.Zero ? maximumGap : TimeSpan.FromMinutes(5); + var segments = new List(); + var current = new List(); + foreach (var sample in samples) + { + if (current.Count > 0 && sample.RecordedAt - current[^1].RecordedAt > gap) + { + segments.Add([.. current]); + current.Clear(); + } + + current.Add(sample); + } + + if (current.Count > 0) + { + segments.Add([.. current]); + } + + return segments; + } + + private static DailyUsage[] CalculateDailyUse( + UsageHistoryEntry[] samples, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + DateTimeOffset now, + TimeSpan maximumGap) + { + var dayCount = Math.Max(1, (int)Math.Ceiling((windowEnd - windowStart).TotalDays)); + var dailyUse = Enumerable.Range(0, dayCount) + .Select(index => new DailyUsage(windowStart.AddDays(index))) + .ToArray(); + var gap = maximumGap > TimeSpan.Zero ? maximumGap : TimeSpan.FromMinutes(5); + + for (var index = 1; index < samples.Length; index++) + { + var previous = samples[index - 1]; + var current = samples[index]; + var rangeStart = previous.RecordedAt < windowStart ? windowStart : previous.RecordedAt; + var rangeEnd = current.RecordedAt > now ? now : current.RecordedAt; + if (rangeEnd <= rangeStart || rangeEnd - rangeStart > gap) + { + continue; + } + + var observedUse = Math.Max(0, previous.RemainingPercent - current.RemainingPercent); + var rangeDuration = rangeEnd - rangeStart; + for (var dayIndex = 0; dayIndex < dailyUse.Length; dayIndex++) + { + var dayStart = dailyUse[dayIndex].Start; + var dayEnd = dayStart.AddDays(1); + var overlapStart = rangeStart > dayStart ? rangeStart : dayStart; + var overlapEnd = rangeEnd < dayEnd ? rangeEnd : dayEnd; + if (overlapEnd <= overlapStart) + { + continue; + } + + dailyUse[dayIndex].HasObservation = true; + dailyUse[dayIndex].ConsumedPercent += observedUse * (overlapEnd - overlapStart).TotalMilliseconds / rangeDuration.TotalMilliseconds; + } + } + + return dailyUse; + } + + private static void AddTrendGrid(XElement document) + { + foreach (var percent in new[] { 100, 50, 0 }) + { + var y = GetTrendY(percent); + document.Add( + new XElement( + Svg + "line", + new XAttribute("x1", Left), + new XAttribute("x2", UsageDashboardCard.BarWidth - Right), + new XAttribute("y1", Format(y)), + new XAttribute("y2", Format(y)), + new XAttribute("stroke", "#7A7A7A"), + new XAttribute("stroke-opacity", "0.42"), + new XAttribute("stroke-width", "1"))); + AddBitmapLabel( + document, + $"{percent}%", + Left - 5, + Math.Clamp(y - BitmapLabelHeight / 2, 0, DailyUseTop - 2 - BitmapLabelHeight), + BitmapLabelAlignment.End, + "vertical"); + } + } + + private static void AddBitmapLabel( + XElement document, + string label, + double anchorX, + double top, + BitmapLabelAlignment alignment, + string axis) + { + var rendered = NormalizeBitmapLabel(label); + var width = MeasureBitmapLabel(rendered); + var left = alignment switch + { + BitmapLabelAlignment.Center => anchorX - width / 2, + BitmapLabelAlignment.End => anchorX - width, + _ => anchorX, + }; + var group = new XElement( + Svg + "g", + new XAttribute("data-axis", axis), + new XAttribute("data-axis-label", label), + new XAttribute("data-rendered-label", rendered)); + + for (var characterIndex = 0; characterIndex < rendered.Length; characterIndex++) + { + var glyph = BitmapGlyphs[rendered[characterIndex]]; + var glyphLeft = left + characterIndex * (BitmapGlyphColumns * BitmapCellSize + BitmapGlyphGap); + for (var row = 0; row < BitmapGlyphRows; row++) + { + for (var column = 0; column < BitmapGlyphColumns; column++) + { + if (glyph[row * BitmapGlyphColumns + column] != '1') + { + continue; + } + + group.Add( + new XElement( + Svg + "rect", + new XAttribute("x", Format(glyphLeft + column * BitmapCellSize)), + new XAttribute("y", Format(top + row * BitmapCellSize)), + new XAttribute("width", Format(BitmapCellSize)), + new XAttribute("height", Format(BitmapCellSize)), + new XAttribute("fill", AxisLabelFill))); + } + } + } + + document.Add(group); + } + + private static string NormalizeBitmapLabel(string label) + { + var normalized = new StringBuilder(label.Length); + foreach (var character in label.Normalize(NormalizationForm.FormD)) + { + if (char.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark) + { + continue; + } + + var glyph = char.ToUpperInvariant(character); + normalized.Append(BitmapGlyphs.ContainsKey(glyph) ? glyph : '?'); + } + + return normalized.Length > 0 ? normalized.ToString() : "?"; + } + + private static string FormatWeekdayLabel(DateTimeOffset day, CultureInfo culture) + { + var label = culture.DateTimeFormat.GetAbbreviatedDayName(day.ToLocalTime().DayOfWeek) + .Trim() + .TrimEnd('.'); + return string.IsNullOrEmpty(label) + ? CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(day.ToLocalTime().DayOfWeek) + : label; + } + + private static double MeasureBitmapLabel(string label) => + label.Length * BitmapGlyphColumns * BitmapCellSize + Math.Max(0, label.Length - 1) * BitmapGlyphGap; + + private static double BitmapLabelHeight => BitmapGlyphRows * BitmapCellSize; + + private static void AddNowMarker( + XElement document, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + DateTimeOffset now) + { + if (now <= windowStart || now >= windowEnd) + { + return; + } + + var x = GetX(now, windowStart, windowEnd); + document.Add( + new XElement( + Svg + "line", + new XAttribute("x1", Format(x)), + new XAttribute("x2", Format(x)), + new XAttribute("y1", TrendTop), + new XAttribute("y2", DailyUseTop + DailyUseHeight), + new XAttribute("stroke", "#C8C8C8"), + new XAttribute("stroke-opacity", "0.45"), + new XAttribute("stroke-width", "1"))); + } + + private static void AddObservedLines( + XElement document, + IReadOnlyList segments, + DateTimeOffset windowStart, + DateTimeOffset windowEnd) + { + var latest = segments.LastOrDefault(segment => segment.Length > 0)?[^1]; + foreach (var segment in segments.Where(segment => segment.Length >= 2)) + { + document.Add( + new XElement( + Svg + "polyline", + new XAttribute("points", FormatPoints(segment, windowStart, windowEnd)), + new XAttribute("fill", "none"), + new XAttribute("stroke", "#5C9EFA"), + new XAttribute("stroke-width", "2"), + new XAttribute("stroke-linecap", "round"), + new XAttribute("stroke-linejoin", "round"))); + } + + foreach (var sample in segments + .Where(segment => segment.Length == 1) + .Select(segment => segment[0]) + .Where(sample => latest is null || sample.RecordedAt != latest.RecordedAt)) + { + document.Add( + new XElement( + Svg + "circle", + new XAttribute("cx", Format(GetX(sample.RecordedAt, windowStart, windowEnd))), + new XAttribute("cy", Format(GetTrendY(sample.RemainingPercent))), + new XAttribute("r", "1.5"), + new XAttribute("fill", "#5C9EFA"))); + } + + if (latest is not null) + { + document.Add( + new XElement( + Svg + "circle", + new XAttribute("cx", Format(GetX(latest.RecordedAt, windowStart, windowEnd))), + new XAttribute("cy", Format(GetTrendY(latest.RemainingPercent))), + new XAttribute("r", "3"), + new XAttribute("fill", "#5C9EFA"), + new XAttribute("stroke", "#121212"), + new XAttribute("stroke-width", "1"))); + } + } + + private static void AddForecastLine( + XElement document, + UsageHistoryEntry[]? latestSegment, + UsageTrendForecast? forecast, + DateTimeOffset windowStart, + DateTimeOffset windowEnd) + { + if (latestSegment is null || forecast is null) + { + return; + } + + var last = latestSegment[^1]; + var end = forecast.EndsAt < windowEnd ? forecast.EndsAt : windowEnd; + if (end <= last.RecordedAt) + { + return; + } + + var points = new List + { + last, + new(end, forecast.RemainingPercent), + }; + if (forecast.ReachesLimitBeforeReset && end < windowEnd) + { + points.Add(new UsageHistoryEntry(windowEnd, 0)); + } + + document.Add( + new XElement( + Svg + "polyline", + new XAttribute("points", FormatPoints(points, windowStart, windowEnd)), + new XAttribute("fill", "none"), + new XAttribute("stroke", "#5C9EFA"), + new XAttribute("stroke-width", "2"), + new XAttribute("stroke-linecap", "round"), + new XAttribute("stroke-linejoin", "round"), + new XAttribute("stroke-dasharray", "5 4"), + new XAttribute("stroke-opacity", "0.9"))); + } + + private static void AddDailyUseBars( + XElement document, + IReadOnlyList dailyUse, + CultureInfo culture) + { + var chartWidth = UsageDashboardCard.BarWidth - Left - Right; + var dayWidth = chartWidth / dailyUse.Count; + var baseline = DailyUseTop + DailyUseHeight; + document.Add( + new XElement( + Svg + "line", + new XAttribute("x1", Left), + new XAttribute("x2", UsageDashboardCard.BarWidth - Right), + new XAttribute("y1", Format(baseline)), + new XAttribute("y2", Format(baseline)), + new XAttribute("stroke", "#7A7A7A"), + new XAttribute("stroke-opacity", "0.65"), + new XAttribute("stroke-width", "1"))); + + for (var index = 0; index < dailyUse.Count; index++) + { + var day = dailyUse[index]; + var x = Left + index * dayWidth + 3; + var width = Math.Max(2, dayWidth - 6); + if (day.HasObservation && day.ConsumedPercent > 0) + { + var height = Math.Max(1, Math.Clamp(day.ConsumedPercent, 0, 100) / 100 * DailyUseHeight); + document.Add( + new XElement( + Svg + "rect", + new XAttribute("x", Format(x)), + new XAttribute("y", Format(baseline - height)), + new XAttribute("width", Format(width)), + new XAttribute("height", Format(height)), + new XAttribute("rx", "1.5"), + new XAttribute("fill", "#7A7A7A"), + new XAttribute("fill-opacity", "0.85"), + new XAttribute("data-series", "daily-use"))); + } + + AddBitmapLabel( + document, + FormatWeekdayLabel(day.Start, culture), + x + width / 2, + DayLabelTop, + BitmapLabelAlignment.Center, + "horizontal"); + } + } + + private static string FormatAltText( + UsageHistoryEntry first, + UsageHistoryEntry last, + int sampleCount, + UsageTrendForecast? forecast, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + IReadOnlyList dailyUse, + CultureInfo culture) + { + var period = $"{windowStart.ToLocalTime().ToString("ddd d MMM HH:mm", culture)} to {windowEnd.ToLocalTime().ToString("ddd d MMM HH:mm", culture)}"; + var daily = dailyUse.Any(day => day.HasObservation) + ? "Daily bars show observed quota consumption." + : "No continuous measurements are available for daily consumption bars."; + var forecastText = forecast switch + { + { ReachesLimitBeforeReset: true } => $" Forecast reaches the limit around {forecast.EndsAt.ToLocalTime().ToString("ddd d MMM HH:mm", culture)}.", + { } => $" Forecast leaves {forecast.RemainingPercent:0}% at reset.", + null => " Forecast is unavailable.", + }; + return $"Weekly quota trend from {period}. Remaining allowance changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observations. The vertical scale is remaining allowance from 0% to 100%; horizontal labels are quota-window weekdays. Solid line and points are observed; dashed line is forecast.{forecastText} {daily}"; + } + + private static string FormatPoints( + IEnumerable points, + DateTimeOffset windowStart, + DateTimeOffset windowEnd) => + string.Join( + " ", + points.Select(point => $"{Format(GetX(point.RecordedAt, windowStart, windowEnd))},{Format(GetTrendY(point.RemainingPercent))}")); + + private static double GetX(DateTimeOffset recordedAt, DateTimeOffset windowStart, DateTimeOffset windowEnd) + { + var duration = windowEnd - windowStart; + if (duration <= TimeSpan.Zero) + { + return Left; + } + + var position = Math.Clamp((recordedAt - windowStart).TotalMilliseconds / duration.TotalMilliseconds, 0, 1); + return Left + position * (UsageDashboardCard.BarWidth - Left - Right); + } + + private static double GetTrendY(double remainingPercent) => + TrendTop + (100 - Math.Clamp(remainingPercent, 0, 100)) / 100 * TrendHeight; + + private static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + + private enum BitmapLabelAlignment + { + Start, + Center, + End, + } + + private sealed class DailyUsage(DateTimeOffset start) + { + public DateTimeOffset Start { get; } = start; + + public double ConsumedPercent { get; set; } + + public bool HasObservation { get; set; } + } +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b12c8da..0e3c7ac 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -85,7 +85,7 @@ Complete every row on a clean x64 environment and a separate clean ARM64 environ | Uninstall and reinstall | Required | Required | Uninstall removes the app and extension registration; reinstall restores discovery without stale or duplicate providers. | | Automated preflight | Required | Required | `test-integration.ps1` passes manifest, package, CLSID, COM, and AppExtension checks. | | Discovery and reload | Required | Required | After **Reload Command Palette Extension**, **Codex Usage** appears once under **Settings > Extensions** and can be enabled. | -| Details page | Required | Required | Opening **Codex Usage** shows five-hour and weekly quota summaries that compare allowance used with elapsed window time and include a projection for each active window. Resets, credits, account status, and source appear in the native Details pane. Verify pace labels, inactive/projection fallback states, and semantic status colors at narrow and wide window sizes, light/dark themes, and high contrast; manual refresh must update both areas without clipping or freezing Command Palette. | +| Details page | Required | Required | Opening **Codex Usage** shows five-hour and weekly quota summaries that compare allowance used with elapsed window time and include a projection for each active window. The weekly view includes a seven-day chart that starts at the quota window start: continuous observed allowance is solid, a fresh projection is dashed, daily bars show only observed consumption, and host-safe axis labels show the 0–100% scale plus localized weekdays. Resets, credits, account status, and source appear in the native Details pane. Verify pace labels, inactive/projection fallback states, chart gap handling, visible axis labels, and semantic status colors at narrow and wide window sizes, light/dark themes, and high contrast; manual refresh must update both areas without clipping or freezing Command Palette. | | 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, and refresh-interval choices apply immediately and persist after restarting Command Palette. | | 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 a968e38..c9f6e1f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ It displays: - the percentage remaining in the weekly usage window; - compact pace indicators that compare allowance used with elapsed window time; - a projected allowance at reset, or an estimated limit time when current consumption would exhaust it sooner; +- a seven-day weekly trend chart with observed remaining allowance, a dashed projection, observed daily quota consumption, a 0–100% vertical scale, and localized weekday labels; - the number of available earned resets and their expiry times; - the remaining credits balance when Codex provides it. From 66f6ffc24f41c39b3fdbae23e0c0b8d849343b9f Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:54:55 +0200 Subject: [PATCH 2/2] Link weekly trend changelog to PR #13 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d11898e..b24fc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,11 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- The weekly usage dashboard now combines observed remaining allowance, a dashed reset projection, and locally observed daily quota consumption in one seven-day trend chart. +- The weekly usage dashboard now combines observed remaining allowance, a dashed reset projection, and locally observed daily quota consumption in one seven-day trend chart. ([PR #13](https://github.com/TheBeems/CodexUsageDock/pull/13)) ### Changed -- The weekly trend chart now labels its 0–100% vertical scale and localized quota-window weekdays without relying on unsupported SVG text. +- The weekly trend chart now labels its 0–100% vertical scale and localized quota-window weekdays without relying on unsupported SVG text. ([PR #13](https://github.com/TheBeems/CodexUsageDock/pull/13)) ## [0.5.2] - 2026-07-16