diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa781d..9410f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,22 @@ Each entry links to the commit or pull request that introduced the change. ## [Unreleased] +### Added + +- Weekly forecasts can now adapt cautiously to up to eight local quota cycles and six-hour usage patterns while retaining the current pace as their primary signal. ([commit e5fdbfb](https://github.com/TheBeems/CodexUsageDock/commit/e5fdbfb46e4b4bbf7cc16875369af15484876e05)) +- Codex Usage settings now let users pause adaptive weekly forecasting and confirm deletion of its learned local history. ([commit e5fdbfb](https://github.com/TheBeems/CodexUsageDock/commit/e5fdbfb46e4b4bbf7cc16875369af15484876e05)) + +### Changed + +- Weekly trend rendering is now validated with fixed English/UTC inputs and an 80 KB SVG URL budget, keeping the size check independent of the build agent locale and time zone. ([commit e075651](https://github.com/TheBeems/CodexUsageDock/commit/e0756515ce4eef9b928f0224283055ea3f3a4f70)) + ### Fixed +- Weekly trend lines now connect sampled values across measurement gaps while retaining breaks for quota increases and keeping daily-use bars and forecasts gap-safe. ([commit e5fdbfb](https://github.com/TheBeems/CodexUsageDock/commit/e5fdbfb46e4b4bbf7cc16875369af15484876e05)) +- Weekly trend calendar days now use equal-width columns, including reset-boundary days, and separate weekday names from their dates in axis labels. ([commit e5fdbfb](https://github.com/TheBeems/CodexUsageDock/commit/e5fdbfb46e4b4bbf7cc16875369af15484876e05)) +- Weekly trend charts now retain observed days before an in-window quota increase, while breaking the line at that increase and keeping the forecast anchored to the latest post-increase segment. ([commit e5fdbfb](https://github.com/TheBeems/CodexUsageDock/commit/e5fdbfb46e4b4bbf7cc16875369af15484876e05)) +- Weekly trend bars and dated weekday labels now follow local calendar days, with partial reset-boundary days and explicit reset and current-time markers. ([commit e5fdbfb](https://github.com/TheBeems/CodexUsageDock/commit/e5fdbfb46e4b4bbf7cc16875369af15484876e05)) +- Microsoft Store packages now hide the extension-only executable from the Start menu, preventing certification and users from treating its COM-server entry point as a standalone app. ([commit e5fdbfb](https://github.com/TheBeems/CodexUsageDock/commit/e5fdbfb46e4b4bbf7cc16875369af15484876e05)) - Weekly trend forecasts are now suppressed when the newest observation is isolated by a data gap, avoiding a projection anchored to stale data. ([commit ead2fac](https://github.com/TheBeems/CodexUsageDock/commit/ead2fac09c2c889cd20236c9e98fbf001bcda4e9)) - Weekly trend charts now fall back to invariant English weekday labels when localized abbreviations use glyphs that the host-safe bitmap renderer cannot display. ([commit ead2fac](https://github.com/TheBeems/CodexUsageDock/commit/ead2fac09c2c889cd20236c9e98fbf001bcda4e9)) - Installation documentation now distinguishes the pending `0.5.0` Store submission and latest GitHub release from later versioned repository changes. ([commit ead2fac](https://github.com/TheBeems/CodexUsageDock/commit/ead2fac09c2c889cd20236c9e98fbf001bcda4e9)) diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 2b462d6..f913de2 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -27,6 +27,7 @@ public void SettingsDefaultToShowingAllDockUsageInformation() Assert.True(settings.ShowWeeklyLimit); Assert.True(settings.ShowResetsAndCredits); Assert.True(settings.ShowResetTime); + Assert.True(settings.UseAdaptiveWeeklyForecast); Assert.Equal(TimeSpan.FromMinutes(1), settings.RefreshInterval); } @@ -206,7 +207,7 @@ public void DetailsPageUsesNativeMediumDetailsPane() 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.Contains("Solid: sampled allowance", main.TemplateJson, StringComparison.Ordinal); Assert.Same(Assert.Single(page.GetContent()), Assert.Single(page.GetContent())); } @@ -253,7 +254,8 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), 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.Contains("Solid line connects sampled values", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + Assert.Equal("Forecast: current pace only.", root.GetProperty("weeklyForecastStatus").GetString()); 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); @@ -271,6 +273,57 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), Assert.Contains("standalone Codex CLI app-server", details, StringComparison.Ordinal); } + [Fact] + public void WeeklyTrendUsesFullWindowHistoryBeforeTheLatestQuotaIncrease() + { + var windowStart = new DateTimeOffset(2026, 7, 16, 11, 59, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = new DateTimeOffset(2026, 7, 17, 10, 36, 0, TimeSpan.Zero); + var snapshot = new CodexUsageSnapshot( + null, + new RateLimitWindow(20, 10080, reset), + null, + null, + null, + now, + UsageDataSource.AppServer, + null); + var data = CodexUsageDockPage.FormatMainDataJson( + snapshot, + now, + isLoading: false, + primaryHistory: [], + weeklyHistory: + [ + new UsageHistoryEntry(windowStart.AddMinutes(1), 100), + new UsageHistoryEntry(windowStart.AddMinutes(5), 85), + new UsageHistoryEntry(windowStart.AddMinutes(6), 96), + new UsageHistoryEntry(windowStart.AddMinutes(10), 90), + new UsageHistoryEntry(new DateTimeOffset(2026, 7, 17, 10, 31, 0, TimeSpan.Zero), 88), + new UsageHistoryEntry(now, 80), + ], + refreshInterval: TimeSpan.FromMinutes(1)); + + using var document = JsonDocument.Parse(data); + var root = document.RootElement; + var chart = ParseSvg(root.GetProperty("weeklyTrendChartUrl").GetString()!); + var chartRoot = Assert.IsType(chart.Root); + var thursdayUseBar = Assert.Single( + chartRoot.Descendants(Svg + "rect"), + rect => rect.Attribute("data-series")?.Value == "daily-use" + && rect.Attribute("data-date")?.Value == "2026-07-16"); + var observedLines = chartRoot.Descendants(Svg + "polyline") + .Where(line => line.Attribute("stroke-dasharray") is null) + .ToArray(); + + Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); + Assert.InRange( + double.Parse(thursdayUseBar.Attribute("height")!.Value, CultureInfo.InvariantCulture), + 22.2, + 22.3); + Assert.Equal(2, observedLines.Length); + } + [Fact] public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() { @@ -368,13 +421,13 @@ public void WeeklyTrendChartRendersObservedForecastAndDailyUse() 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.Contains("Solid line connects sampled values", result.AltText, StringComparison.Ordinal); Assert.DoesNotContain("NaN", result.ImageUrl, StringComparison.Ordinal); Assert.DoesNotContain("Infinity", result.ImageUrl, StringComparison.Ordinal); } [Fact] - public void WeeklyTrendChartBreaksGapsAndExcludesUnobservedDailyUse() + public void WeeklyTrendChartConnectsGapsWithoutAddingUnobservedDailyUse() { var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); var reset = windowStart.AddDays(7); @@ -402,10 +455,50 @@ public void WeeklyTrendChartBreaksGapsAndExcludesUnobservedDailyUse() .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); + var observedPoints = Assert.Single(observedLines) + .Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(4, observedPoints.Length); Assert.InRange(observedBarHeight, 21.1, 21.3); } + [Fact] + public void WeeklyTrendChartBreaksAtQuotaIncreaseAndRetainsCalendarDayUse() + { + var windowStart = new DateTimeOffset(2026, 7, 16, 11, 59, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = windowStart.AddMinutes(10); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(windowStart.AddMinutes(1), 100), + new UsageHistoryEntry(windowStart.AddMinutes(5), 85), + new UsageHistoryEntry(windowStart.AddMinutes(6), 96), + new UsageHistoryEntry(now, 86), + ], + new RateLimitWindow(14, 10080, reset), + now, + TimeSpan.FromMinutes(15), + forecast: null, + timeZone: TimeZoneInfo.Utc); + + var result = Assert.IsType(chart); + var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); + var observedLines = root.Descendants(Svg + "polyline") + .Where(line => line.Attribute("stroke-dasharray") is null) + .ToArray(); + var dailyUseBar = Assert.Single( + root.Descendants(Svg + "rect"), + rect => rect.Attribute("data-series")?.Value == "daily-use" + && rect.Attribute("data-date")?.Value == "2026-07-16"); + + Assert.Equal(2, observedLines.Length); + Assert.InRange( + double.Parse(dailyUseBar.Attribute("height")!.Value, CultureInfo.InvariantCulture), + 26.4, + 26.6); + Assert.Contains("line breaks mark allowance increases or resets", result.AltText, StringComparison.Ordinal); + } + [Fact] public void WeeklyTrendChartOmitsForecastWhenLatestObservationIsGapIsolated() { @@ -426,6 +519,7 @@ public void WeeklyTrendChartOmitsForecastWhenLatestObservationIsGapIsolated() var result = Assert.IsType(chart); var svg = ParseSvg(result.ImageUrl); + Assert.Single(svg.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is null); Assert.DoesNotContain(svg.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null); Assert.Contains("Forecast is unavailable", result.AltText, StringComparison.Ordinal); } @@ -446,7 +540,8 @@ public void WeeklyTrendChartRendersHostSafeLocalizedAxisLabels() now, TimeSpan.FromDays(2), forecast: null, - culture: culture); + culture: culture, + timeZone: TimeZoneInfo.Utc); var result = Assert.IsType(chart); var svg = ParseSvg(result.ImageUrl); @@ -461,8 +556,8 @@ public void WeeklyTrendChartRendersHostSafeLocalizedAxisLabels() .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") + Assert.Equal(["ma 13", "di 14", "wo 15", "do 16", "vr 17", "za 18", "zo 19", "ma 20"], horizontalLabels); + Assert.Equal(["MA 13", "DI 14", "WO 15", "DO 16", "VR 17", "ZA 18", "ZO 19", "MA 20"], 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")); @@ -487,7 +582,8 @@ public void WeeklyTrendChartFallsBackToInvariantWeekdaysForUnsupportedGlyphs() now, TimeSpan.FromDays(2), forecast: null, - culture: culture); + culture: culture, + timeZone: TimeZoneInfo.Utc); var result = Assert.IsType(chart); var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); @@ -495,12 +591,94 @@ public void WeeklyTrendChartFallsBackToInvariantWeekdaysForUnsupportedGlyphs() .Where(group => group.Attribute("data-axis")?.Value == "horizontal") .ToArray(); - Assert.Equal(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + Assert.Equal(["Mon 13", "Tue 14", "Wed 15", "Thu 16", "Fri 17", "Sat 18", "Sun 19", "Mon 20"], horizontalLabels.Select(group => group.Attribute("data-axis-label")!.Value)); Assert.All(horizontalLabels, group => Assert.DoesNotContain('?', group.Attribute("data-rendered-label")!.Value)); } + [Fact] + public void WeeklyTrendChartAttributesUseToLocalCalendarDays() + { + var culture = CultureInfo.GetCultureInfo("nl-NL"); + var windowStart = new DateTimeOffset(2026, 7, 16, 11, 59, 37, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = new DateTimeOffset(2026, 7, 17, 16, 25, 13, TimeSpan.Zero); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(new DateTimeOffset(2026, 7, 17, 10, 31, 38, TimeSpan.Zero), 88), + new UsageHistoryEntry(new DateTimeOffset(2026, 7, 17, 11, 59, 32, TimeSpan.Zero), 73), + new UsageHistoryEntry(new DateTimeOffset(2026, 7, 17, 16, 24, 17, TimeSpan.Zero), 64), + ], + new RateLimitWindow(36, 10080, reset), + now, + TimeSpan.FromDays(1), + forecast: null, + culture: culture, + timeZone: TimeZoneInfo.Utc); + + var result = Assert.IsType(chart); + var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); + var dailyUseBar = Assert.Single( + root.Descendants(Svg + "rect"), + rect => rect.Attribute("data-series")?.Value == "daily-use"); + 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("2026-07-17", dailyUseBar.Attribute("data-date")?.Value); + Assert.Equal("false", dailyUseBar.Attribute("data-partial")?.Value); + Assert.Equal("true", dailyUseBar.Attribute("data-current")?.Value); + Assert.Equal(["do 16", "vr 17", "za 18", "zo 19", "ma 20", "di 21", "wo 22", "do 23"], horizontalLabels); + Assert.Equal(7, root.Descendants(Svg + "line").Count(line => line.Attribute("data-grid")?.Value == "calendar-day")); + Assert.Equal(2, root.Descendants(Svg + "line").Count(line => line.Attribute("data-marker")?.Value?.StartsWith("reset-", StringComparison.Ordinal) == true)); + Assert.Single(root.Descendants(Svg + "line"), line => line.Attribute("data-marker")?.Value == "now"); + Assert.Contains("horizontal labels are local calendar dates", result.AltText, StringComparison.Ordinal); + } + + [Fact] + public void WeeklyTrendChartUsesEqualWidthCalendarDayColumns() + { + var windowStart = new DateTimeOffset(2026, 7, 16, 11, 59, 0, TimeSpan.Zero); + var reset = windowStart.AddDays(7); + var now = new DateTimeOffset(2026, 7, 17, 0, 5, 0, TimeSpan.Zero); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(windowStart.AddMinutes(1), 100), + new UsageHistoryEntry(windowStart.AddMinutes(5), 90), + new UsageHistoryEntry(new DateTimeOffset(2026, 7, 17, 0, 1, 0, TimeSpan.Zero), 85), + new UsageHistoryEntry(now, 75), + ], + new RateLimitWindow(25, 10080, reset), + now, + TimeSpan.FromMinutes(15), + forecast: null, + culture: CultureInfo.GetCultureInfo("nl-NL"), + timeZone: TimeZoneInfo.Utc); + + var result = Assert.IsType(chart); + var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); + var dailyUseBars = root.Descendants(Svg + "rect") + .Where(rect => rect.Attribute("data-series")?.Value == "daily-use") + .OrderBy(rect => double.Parse(rect.Attribute("x")!.Value, CultureInfo.InvariantCulture)) + .ToArray(); + var firstX = double.Parse(dailyUseBars[0].Attribute("x")!.Value, CultureInfo.InvariantCulture); + var secondX = double.Parse(dailyUseBars[1].Attribute("x")!.Value, CultureInfo.InvariantCulture); + var firstWidth = double.Parse(dailyUseBars[0].Attribute("width")!.Value, CultureInfo.InvariantCulture); + var secondWidth = double.Parse(dailyUseBars[1].Attribute("width")!.Value, CultureInfo.InvariantCulture); + var labels = root.Descendants(Svg + "g") + .Where(group => group.Attribute("data-axis")?.Value == "horizontal") + .Select(group => group.Attribute("data-axis-label")!.Value) + .ToArray(); + + Assert.Equal("true", dailyUseBars[0].Attribute("data-partial")?.Value); + Assert.Equal("false", dailyUseBars[1].Attribute("data-partial")?.Value); + Assert.Equal(firstWidth, secondWidth, precision: 3); + Assert.Equal(firstWidth + 6, secondX - firstX, precision: 3); + Assert.Equal(["do 16", "vr 17", "za 18", "zo 19", "ma 20", "di 21", "wo 22", "do 23"], labels); + } + [Fact] public void WeeklyTrendChartUsesTheZeroGridlineAsTheSharedBarBaseline() { @@ -546,7 +724,7 @@ public void WeeklyTrendChartUsesTheZeroGridlineAsTheSharedBarBaseline() } [Fact] - public void WeeklyTrendChartKeepsIsolatedObservationsAsPoints() + public void WeeklyTrendChartConnectsGapIsolatedObservationsAsASampledLine() { var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); var reset = windowStart.AddDays(7); @@ -564,8 +742,11 @@ public void WeeklyTrendChartKeepsIsolatedObservationsAsPoints() var result = Assert.IsType(chart); var svg = ParseSvg(result.ImageUrl); - Assert.Empty(svg.Descendants(Svg + "polyline")); - Assert.Equal(2, svg.Descendants(Svg + "circle").Count()); + var observed = Assert.Single(svg.Descendants(Svg + "polyline")); + var points = observed.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(2, points.Length); + Assert.Single(svg.Descendants(Svg + "circle")); } [Fact] @@ -607,7 +788,9 @@ public void WeeklyTrendChartBoundsMinuteHistory() new RateLimitWindow(50, 10080, reset), now, TimeSpan.FromMinutes(15), - forecast: null); + forecast: null, + culture: CultureInfo.GetCultureInfo("en-US"), + timeZone: TimeZoneInfo.Utc); var result = Assert.IsType(chart); var svg = ParseSvg(result.ImageUrl); @@ -615,7 +798,7 @@ public void WeeklyTrendChartBoundsMinuteHistory() var points = observed.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); Assert.InRange(points.Length, 2, WeeklyUsageTrendChartRenderer.MaximumRenderedPoints); - Assert.True(result.ImageUrl.Length < 50000); + Assert.True(result.ImageUrl.Length < 80000, result.ImageUrl.Length.ToString(CultureInfo.InvariantCulture)); } [Fact] @@ -1511,4 +1694,171 @@ public void TrendAllowsFreshnessMatchingTheConfiguredRefreshInterval() Assert.DoesNotContain("latest measurement is too old", trend, StringComparison.Ordinal); } + + [Fact] + public void AdaptiveWeeklyUsageStoreLearnsOnlyContinuousAllowanceDecreasesAndSealsAtReset() + { + var temporaryDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var path = Path.Combine(temporaryDirectory, "adaptive-weekly.json"); + var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero); + var start = reset.AddDays(-7); + var window = new RateLimitWindow(0, 10080, reset); + try + { + var store = new AdaptiveWeeklyUsageStore(path); + store.Record( + window, + [ + new UsageHistoryEntry(start.AddMinutes(1), 100), + new UsageHistoryEntry(start.AddMinutes(2), 90), + new UsageHistoryEntry(start.AddMinutes(3), 95), + new UsageHistoryEntry(start.AddMinutes(4), 85), + new UsageHistoryEntry(start.AddHours(8), 80), + ], + TimeSpan.FromMinutes(5)); + + var active = Assert.IsType(store.Snapshot.ActiveCycle); + Assert.Equal(2, active.ObservedMinutes, precision: 6); + Assert.Equal(20, active.ConsumedPercent, precision: 6); + var bucket = Assert.Single(active.Buckets); + Assert.Equal(0, bucket.Index); + Assert.Equal(20, bucket.ConsumedPercent, precision: 6); + + var restored = new AdaptiveWeeklyUsageStore(path); + Assert.Equal(20, Assert.IsType(restored.Snapshot.ActiveCycle).ConsumedPercent, precision: 6); + + var nextReset = reset.AddDays(7); + store.Record( + new RateLimitWindow(0, 10080, nextReset), + [new UsageHistoryEntry(nextReset.AddDays(-7).AddMinutes(1), 100)], + TimeSpan.FromMinutes(5)); + + Assert.Single(store.Snapshot.CompletedCycles); + } + finally + { + Directory.Delete(temporaryDirectory, recursive: true); + } + } + + [Fact] + public void ClearingAdaptiveWeeklyHistoryDoesNotRelearnOlderRawSamples() + { + var temporaryDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var path = Path.Combine(temporaryDirectory, "adaptive-weekly.json"); + var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero); + var start = reset.AddDays(-7); + var samples = new[] + { + new UsageHistoryEntry(start.AddMinutes(1), 100), + new UsageHistoryEntry(start.AddMinutes(2), 90), + new UsageHistoryEntry(start.AddMinutes(3), 80), + }; + try + { + var store = new AdaptiveWeeklyUsageStore(path); + var window = new RateLimitWindow(0, 10080, reset); + store.Record(window, samples, TimeSpan.FromMinutes(5)); + Assert.True(Assert.IsType(store.Snapshot.ActiveCycle).ConsumedPercent > 0); + + store.Clear(); + store.Record(window, samples, TimeSpan.FromMinutes(5)); + + var active = Assert.IsType(store.Snapshot.ActiveCycle); + Assert.Equal(0, active.ObservedMinutes, precision: 6); + Assert.Equal(0, active.ConsumedPercent, precision: 6); + } + finally + { + Directory.Delete(temporaryDirectory, recursive: true); + } + } + + [Fact] + public void AdaptiveWeeklyUsageStoreRetainsOnlyEightCompletedCycles() + { + var temporaryDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var path = Path.Combine(temporaryDirectory, "adaptive-weekly.json"); + var firstReset = new DateTimeOffset(2026, 1, 9, 0, 0, 0, TimeSpan.Zero); + try + { + var store = new AdaptiveWeeklyUsageStore(path); + for (var index = 0; index < 10; index++) + { + var reset = firstReset.AddDays(7 * index); + var start = reset.AddDays(-7); + store.Record( + new RateLimitWindow(0, 10080, reset), + [ + new UsageHistoryEntry(start.AddMinutes(1), 100), + new UsageHistoryEntry(start.AddMinutes(2), 99), + ], + TimeSpan.FromMinutes(5)); + } + + var completed = store.Snapshot.CompletedCycles; + Assert.Equal(8, completed.Length); + Assert.Equal(firstReset.AddDays(7), completed[0].ResetsAt); + } + finally + { + Directory.Delete(temporaryDirectory, recursive: true); + } + } + + [Fact] + public void AdaptiveForecastUsesSixHourPatternAfterThreeCompletedCycles() + { + var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero); + var start = reset.AddDays(-7); + var cycles = Enumerable.Range(1, 3) + .Select(offset => new AdaptiveWeeklyUsageCycle( + reset.AddDays(-7 * offset), + 10080, + 60, + 6, + [new AdaptiveWeeklyUsageBucket(1, 60, 12)])) + .ToArray(); + var history = new AdaptiveWeeklyUsageHistory(cycles, null); + var latest = new UsageHistoryEntry(start.AddHours(6), 50); + + var adaptive = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, true, history); + var currentOnly = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, false, history); + + Assert.Equal("Forecast: current pace + local history (3/8 cycles).", adaptive.Status); + Assert.True(adaptive.Forecast.EndsAt < currentOnly.Forecast.EndsAt); + Assert.Equal("Forecast: current pace only.", currentOnly.Status); + } + + [Fact] + public void WeeklyChartRendersEveryAdaptiveForecastPoint() + { + var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero); + var start = reset.AddDays(-7); + var now = start.AddDays(2); + var chart = WeeklyUsageTrendChartRenderer.Create( + [ + new UsageHistoryEntry(start.AddMinutes(1), 100), + new UsageHistoryEntry(now.AddMinutes(-1), 81), + new UsageHistoryEntry(now, 80), + ], + new RateLimitWindow(20, 10080, reset), + now, + TimeSpan.FromMinutes(5), + new UsageTrendForecast( + reset, + 20, + false, + [ + new UsageTrendForecastPoint(now.AddHours(6), 75), + new UsageTrendForecastPoint(now.AddHours(12), 68), + new UsageTrendForecastPoint(reset, 20), + ])); + + var document = ParseSvg(Assert.IsType(chart).ImageUrl); + var dashed = Assert.Single( + document.Descendants(Svg + "polyline"), + element => (string?)element.Attribute("stroke-dasharray") == "5 4"); + Assert.True(((string?)dashed.Attribute("points"))!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length >= 4); + } } diff --git a/CodexUsageDock/AdaptiveWeeklyForecast.cs b/CodexUsageDock/AdaptiveWeeklyForecast.cs new file mode 100644 index 0000000..ac6b929 --- /dev/null +++ b/CodexUsageDock/AdaptiveWeeklyForecast.cs @@ -0,0 +1,469 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexUsageDock; + +internal sealed record AdaptiveWeeklyUsageBucket(int Index, double ObservedMinutes, double ConsumedPercent); + +internal sealed record AdaptiveWeeklyUsageCycle( + DateTimeOffset ResetsAt, + int WindowMinutes, + double ObservedMinutes, + double ConsumedPercent, + AdaptiveWeeklyUsageBucket[] Buckets); + +internal sealed record AdaptiveWeeklyUsageState( + AdaptiveWeeklyUsageCycle[] CompletedCycles, + AdaptiveWeeklyUsageCycle? ActiveCycle, + UsageHistoryEntry? LastSample, + bool IsInitialized); + +internal sealed record AdaptiveWeeklyUsageHistory( + AdaptiveWeeklyUsageCycle[] CompletedCycles, + AdaptiveWeeklyUsageCycle? ActiveCycle); + +internal sealed record AdaptiveWeeklyForecastProjection(UsageTrendForecast Forecast, string Status); + +internal sealed class AdaptiveWeeklyUsageStore +{ + internal const int MaximumCompletedCycles = 8; + internal const int BucketCount = 28; + internal static readonly TimeSpan BucketDuration = TimeSpan.FromHours(6); + + private const string FileName = "adaptive-weekly-forecast.json"; + private readonly string _path; + private AdaptiveWeeklyUsageState _state; + + internal AdaptiveWeeklyUsageStore(string path) + { + _path = path; + _state = Load(); + } + + internal static AdaptiveWeeklyUsageStore CreateDefault() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodexUsageDock"); + return new AdaptiveWeeklyUsageStore(Path.Combine(directory, FileName)); + } + + internal AdaptiveWeeklyUsageHistory Snapshot => new( + _state.CompletedCycles.ToArray(), + _state.ActiveCycle); + + internal void Record( + RateLimitWindow? window, + IReadOnlyList weeklyHistory, + TimeSpan maximumGap) + { + if (window is null + || window.WindowMinutes != (int)TimeSpan.FromDays(7).TotalMinutes + || maximumGap <= TimeSpan.Zero) + { + return; + } + + var windowStart = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); + var samples = weeklyHistory + .Where(sample => sample.RecordedAt >= windowStart + && sample.RecordedAt <= window.ResetsAt + && IsValidSample(sample)) + .OrderBy(sample => sample.RecordedAt) + .DistinctBy(sample => sample.RecordedAt) + .ToArray(); + + var active = _state.ActiveCycle; + var replayHistory = !_state.IsInitialized; + if (active is null || active.ResetsAt != window.ResetsAt || active.WindowMinutes != window.WindowMinutes) + { + var completed = _state.CompletedCycles; + if (active is not null && active.ObservedMinutes > 0) + { + completed = completed + .Append(active) + .OrderBy(cycle => cycle.ResetsAt) + .TakeLast(MaximumCompletedCycles) + .ToArray(); + } + + replayHistory |= active is not null; + active = CreateCycle(window); + _state = new AdaptiveWeeklyUsageState(completed, active, null, true); + } + + var replay = replayHistory + ? samples + : _state.LastSample is null + ? samples.TakeLast(1) + : samples.Where(sample => sample.RecordedAt > _state.LastSample.RecordedAt); + foreach (var sample in replay) + { + ApplySample(sample, maximumGap); + } + + Save(); + } + + internal void Clear() + { + // Keep only an empty initialization marker so old raw chart samples are not learnt again. + _state = new AdaptiveWeeklyUsageState([], null, null, true); + Save(); + } + + private static AdaptiveWeeklyUsageCycle CreateCycle(RateLimitWindow window) => new( + window.ResetsAt, + window.WindowMinutes, + 0, + 0, + []); + + private void ApplySample(UsageHistoryEntry sample, TimeSpan maximumGap) + { + var active = _state.ActiveCycle; + if (active is null) + { + return; + } + + var previous = _state.LastSample; + if (previous is null || sample.RecordedAt <= previous.RecordedAt) + { + _state = _state with { LastSample = sample }; + return; + } + + var interval = sample.RecordedAt - previous.RecordedAt; + if (interval <= maximumGap && sample.RemainingPercent <= previous.RemainingPercent) + { + active = AddObservation(active, previous, sample); + } + + _state = _state with { ActiveCycle = active, LastSample = sample }; + } + + private static AdaptiveWeeklyUsageCycle AddObservation( + AdaptiveWeeklyUsageCycle cycle, + UsageHistoryEntry previous, + UsageHistoryEntry current) + { + var interval = current.RecordedAt - previous.RecordedAt; + if (interval <= TimeSpan.Zero) + { + return cycle; + } + + var cycleStart = cycle.ResetsAt - TimeSpan.FromMinutes(cycle.WindowMinutes); + var buckets = cycle.Buckets.ToDictionary(bucket => bucket.Index); + var consumed = Math.Max(0, previous.RemainingPercent - current.RemainingPercent); + var segmentStart = previous.RecordedAt; + while (segmentStart < current.RecordedAt) + { + var elapsed = segmentStart - cycleStart; + var bucketIndex = Math.Clamp((int)Math.Floor(elapsed.TotalHours / BucketDuration.TotalHours), 0, BucketCount - 1); + var bucketEnd = Min( + current.RecordedAt, + cycleStart + TimeSpan.FromTicks(BucketDuration.Ticks * (bucketIndex + 1))); + var duration = bucketEnd - segmentStart; + if (duration <= TimeSpan.Zero) + { + break; + } + + var share = duration.TotalMilliseconds / interval.TotalMilliseconds; + buckets.TryGetValue(bucketIndex, out var existing); + buckets[bucketIndex] = new AdaptiveWeeklyUsageBucket( + bucketIndex, + (existing?.ObservedMinutes ?? 0) + duration.TotalMinutes, + (existing?.ConsumedPercent ?? 0) + consumed * share); + segmentStart = bucketEnd; + } + + return cycle with + { + ObservedMinutes = cycle.ObservedMinutes + interval.TotalMinutes, + ConsumedPercent = cycle.ConsumedPercent + consumed, + Buckets = buckets.Values.OrderBy(bucket => bucket.Index).ToArray(), + }; + } + + private AdaptiveWeeklyUsageState Load() + { + try + { + if (!File.Exists(_path)) + { + return new AdaptiveWeeklyUsageState([], null, null, false); + } + + var state = JsonSerializer.Deserialize(File.ReadAllText(_path), AdaptiveWeeklyUsageJsonContext.Default.AdaptiveWeeklyUsageState); + return Normalize(state); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) + { + return new AdaptiveWeeklyUsageState([], null, null, false); + } + } + + private void Save() + { + var temporaryPath = $"{_path}.{Guid.NewGuid():N}.tmp"; + try + { + var directory = Path.GetDirectoryName(_path); + if (string.IsNullOrWhiteSpace(directory)) + { + return; + } + + Directory.CreateDirectory(directory); + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(_state, AdaptiveWeeklyUsageJsonContext.Default.AdaptiveWeeklyUsageState)); + File.Move(temporaryPath, _path, overwrite: true); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) + { + } + finally + { + try + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + } + } + } + + private static AdaptiveWeeklyUsageState Normalize(AdaptiveWeeklyUsageState? state) + { + if (state is null) + { + return new AdaptiveWeeklyUsageState([], null, null, false); + } + + var completed = (state.CompletedCycles ?? []) + .Select(NormalizeCycle) + .Where(cycle => cycle is not null) + .Cast() + .OrderBy(cycle => cycle.ResetsAt) + .DistinctBy(cycle => cycle.ResetsAt) + .TakeLast(MaximumCompletedCycles) + .ToArray(); + var active = NormalizeCycle(state.ActiveCycle); + var lastSample = active is not null && state.LastSample is { } sample && IsValidSample(sample) + ? sample + : null; + return new AdaptiveWeeklyUsageState(completed, active, lastSample, state.IsInitialized); + } + + private static AdaptiveWeeklyUsageCycle? NormalizeCycle(AdaptiveWeeklyUsageCycle? cycle) + { + if (cycle is null + || cycle.WindowMinutes != (int)TimeSpan.FromDays(7).TotalMinutes + || !double.IsFinite(cycle.ObservedMinutes) + || !double.IsFinite(cycle.ConsumedPercent) + || cycle.ObservedMinutes < 0 + || cycle.ConsumedPercent < 0) + { + return null; + } + + var buckets = (cycle.Buckets ?? []) + .Where(bucket => bucket.Index is >= 0 and < BucketCount + && double.IsFinite(bucket.ObservedMinutes) + && double.IsFinite(bucket.ConsumedPercent) + && bucket.ObservedMinutes >= 0 + && bucket.ConsumedPercent >= 0) + .GroupBy(bucket => bucket.Index) + .Select(group => new AdaptiveWeeklyUsageBucket( + group.Key, + group.Sum(bucket => bucket.ObservedMinutes), + group.Sum(bucket => bucket.ConsumedPercent))) + .OrderBy(bucket => bucket.Index) + .ToArray(); + return cycle with { Buckets = buckets }; + } + + private static bool IsValidSample(UsageHistoryEntry sample) => + double.IsFinite(sample.RemainingPercent) && sample.RemainingPercent is >= 0 and <= 100; + + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right; +} + +internal static class AdaptiveWeeklyForecast +{ + private const double BootstrapWeight = 0.25; + private const double MatureWeight = 0.35; + private const int MinimumBucketCycles = 3; + + internal static AdaptiveWeeklyForecastProjection Project( + UsageHistoryEntry latest, + DateTimeOffset windowStart, + DateTimeOffset resetsAt, + double currentRatePerMinute, + bool enabled, + AdaptiveWeeklyUsageHistory? history) + { + if (!enabled || history is null) + { + return new AdaptiveWeeklyForecastProjection( + ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute), + "Forecast: current pace only."); + } + + var completed = history.CompletedCycles + .Where(cycle => cycle.WindowMinutes == (int)TimeSpan.FromDays(7).TotalMinutes && GetRate(cycle) is not null) + .OrderByDescending(cycle => cycle.ResetsAt) + .Take(AdaptiveWeeklyUsageStore.MaximumCompletedCycles) + .ToArray(); + var provisional = history.ActiveCycle is { } active + && active.ResetsAt == resetsAt + && active.WindowMinutes == (int)TimeSpan.FromDays(7).TotalMinutes + && GetRate(active) is not null + ? active + : null; + var profiles = completed.Length > 0 + ? completed + : provisional is null ? [] : [provisional]; + if (profiles.Length == 0) + { + return new AdaptiveWeeklyForecastProjection( + ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute), + "Forecast: current pace only."); + } + + var historicalRate = Median(profiles.Select(cycle => GetRate(cycle)!.Value)); + var historicalWeight = completed.Length == 0 + ? BootstrapWeight + : BootstrapWeight + (Math.Min(completed.Length, AdaptiveWeeklyUsageStore.MaximumCompletedCycles) - 1) + * (MatureWeight - BootstrapWeight) / (AdaptiveWeeklyUsageStore.MaximumCompletedCycles - 1); + var projected = ProjectByBucket( + latest, + windowStart, + resetsAt, + currentRatePerMinute, + historicalRate, + historicalWeight, + profiles); + var status = completed.Length == 0 + ? "Forecast: current pace + limited local history." + : $"Forecast: current pace + local history ({completed.Length}/{AdaptiveWeeklyUsageStore.MaximumCompletedCycles} cycles)."; + return new AdaptiveWeeklyForecastProjection(projected, status); + } + + private static UsageTrendForecast ProjectByBucket( + UsageHistoryEntry latest, + DateTimeOffset windowStart, + DateTimeOffset resetsAt, + double currentRatePerMinute, + double historicalRatePerMinute, + double historicalWeight, + IReadOnlyList profiles) + { + var points = new List(); + var cursor = latest.RecordedAt; + var remaining = latest.RemainingPercent; + while (cursor < resetsAt && remaining > 0) + { + var bucketIndex = GetBucketIndex(cursor, windowStart); + var bucketEnd = Min(resetsAt, windowStart + TimeSpan.FromTicks(AdaptiveWeeklyUsageStore.BucketDuration.Ticks * (bucketIndex + 1))); + if (bucketEnd <= cursor) + { + break; + } + + var bucketFactor = GetBucketFactor(profiles, bucketIndex); + var rate = currentRatePerMinute * (1 - historicalWeight) + historicalRatePerMinute * bucketFactor * historicalWeight; + if (rate <= 0 || !double.IsFinite(rate)) + { + return ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute); + } + + var capacity = rate * (bucketEnd - cursor).TotalMinutes; + if (capacity >= remaining) + { + var endsAt = cursor.AddMinutes(remaining / rate); + points.Add(new UsageTrendForecastPoint(endsAt, 0)); + return new UsageTrendForecast(endsAt, 0, true, points); + } + + remaining -= capacity; + cursor = bucketEnd; + points.Add(new UsageTrendForecastPoint(cursor, remaining)); + } + + return new UsageTrendForecast(resetsAt, Math.Max(0, remaining), false, points); + } + + private static UsageTrendForecast ProjectAtCurrentRate( + UsageHistoryEntry latest, + DateTimeOffset resetsAt, + double currentRatePerMinute) + { + var minutesToEmpty = latest.RemainingPercent / currentRatePerMinute; + var estimated = latest.RecordedAt.AddMinutes(minutesToEmpty); + if (estimated >= resetsAt) + { + var remainingAtReset = Math.Max(0, latest.RemainingPercent - currentRatePerMinute * (resetsAt - latest.RecordedAt).TotalMinutes); + return new UsageTrendForecast( + resetsAt, + remainingAtReset, + false, + [new UsageTrendForecastPoint(resetsAt, remainingAtReset)]); + } + + return new UsageTrendForecast( + estimated, + 0, + true, + [new UsageTrendForecastPoint(estimated, 0)]); + } + + private static double GetBucketFactor(IReadOnlyList profiles, int bucketIndex) + { + var factors = profiles + .Select(cycle => + { + var rate = GetRate(cycle); + var bucket = cycle.Buckets.FirstOrDefault(candidate => candidate.Index == bucketIndex); + return rate is > 0 && bucket is { ObservedMinutes: > 0 } + ? bucket.ConsumedPercent / bucket.ObservedMinutes / rate.Value + : double.NaN; + }) + .Where(double.IsFinite) + .Where(factor => factor >= 0) + .ToArray(); + return factors.Length >= MinimumBucketCycles + ? Math.Clamp(Median(factors), 0, 4) + : 1; + } + + private static double? GetRate(AdaptiveWeeklyUsageCycle cycle) => + cycle.ObservedMinutes > 0 && double.IsFinite(cycle.ObservedMinutes) && double.IsFinite(cycle.ConsumedPercent) + ? cycle.ConsumedPercent / cycle.ObservedMinutes + : null; + + private static int GetBucketIndex(DateTimeOffset instant, DateTimeOffset windowStart) => + Math.Clamp((int)Math.Floor((instant - windowStart).TotalHours / AdaptiveWeeklyUsageStore.BucketDuration.TotalHours), 0, AdaptiveWeeklyUsageStore.BucketCount - 1); + + private static double Median(IEnumerable values) + { + var ordered = values.OrderBy(value => value).ToArray(); + var middle = ordered.Length / 2; + return ordered.Length % 2 == 0 + ? (ordered[middle - 1] + ordered[middle]) / 2 + : ordered[middle]; + } + + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right; +} + +[JsonSerializable(typeof(AdaptiveWeeklyUsageState))] +internal sealed partial class AdaptiveWeeklyUsageJsonContext : JsonSerializerContext +{ +} diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index e3f77be..3f0fe89 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -40,6 +40,8 @@ public CodexUsageDockCommandsProvider() ]; _settings.Changed += OnSettingsChanged; + _settings.ClearAdaptiveHistoryRequested += OnClearAdaptiveHistoryRequested; + _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); RebuildDockBands(); _usage.Start(); @@ -52,12 +54,19 @@ public CodexUsageDockCommandsProvider() private void OnSettingsChanged(object? sender, EventArgs e) { _usage.SetRefreshInterval(_settings.RefreshInterval); + _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); _fiveHour.Refresh(); _weekly.Refresh(); RebuildDockBands(); RaiseItemsChanged(); } + private void OnClearAdaptiveHistoryRequested(object? sender, EventArgs e) + { + _usage.ClearAdaptiveWeeklyHistory(); + _ = _usage.RefreshAsync(); + } + private void RebuildDockBands() { var items = new List(3); @@ -84,6 +93,7 @@ private void RebuildDockBands() public override void Dispose() { _settings.Changed -= OnSettingsChanged; + _settings.ClearAdaptiveHistoryRequested -= OnClearAdaptiveHistoryRequested; _fiveHour.Dispose(); _weekly.Dispose(); _resetsAndCredits.Dispose(); diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index 3d077c9..db4b266 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -13,6 +13,7 @@ internal sealed partial class CodexUsageService : IDisposable private readonly List _primaryHistory = []; private readonly List _weeklyHistory = []; private readonly WeeklyUsageHistoryStore _weeklyHistoryStore; + private readonly AdaptiveWeeklyUsageStore _adaptiveWeeklyUsageStore; private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly Func> _appServerReader; private readonly Func _localSessionReader; @@ -20,6 +21,7 @@ internal sealed partial class CodexUsageService : IDisposable private bool _disposed; private bool _isLoading; private bool _started; + private bool _adaptiveWeeklyForecastEnabled = true; public CodexUsageService() : this(CodexAppServerReader.ReadAsync, LocalCodexSessionReader.ReadLatest) @@ -29,19 +31,22 @@ public CodexUsageService() internal CodexUsageService( Func> appServerReader, Func localSessionReader, - WeeklyUsageHistoryStore? weeklyHistoryStore = null) + WeeklyUsageHistoryStore? weeklyHistoryStore = null, + AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null) { _appServerReader = appServerReader; _localSessionReader = localSessionReader; _weeklyHistoryStore = weeklyHistoryStore ?? WeeklyUsageHistoryStore.CreateDefault(); + _adaptiveWeeklyUsageStore = adaptiveWeeklyUsageStore ?? AdaptiveWeeklyUsageStore.CreateDefault(); _weeklyHistory.AddRange(_weeklyHistoryStore.Load(DateTimeOffset.Now)); } internal CodexUsageService( Func> appServerReader, Func localSessionReader, - WeeklyUsageHistoryStore? weeklyHistoryStore = null) - : this(appServerReader, _ => localSessionReader(), weeklyHistoryStore) + WeeklyUsageHistoryStore? weeklyHistoryStore = null, + AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null) + : this(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore) { } @@ -82,6 +87,17 @@ public IReadOnlyList WeeklyHistory public TimeSpan RefreshInterval => TimeSpan.FromMilliseconds(_timer.Interval); + public AdaptiveWeeklyUsageHistory AdaptiveWeeklyHistory + { + get + { + lock (_historyLock) + { + return _adaptiveWeeklyUsageStore.Snapshot; + } + } + } + public event EventHandler? Updated; public void Start() @@ -111,6 +127,29 @@ internal void SetRefreshInterval(TimeSpan interval) _timer.Interval = interval.TotalMilliseconds; } + internal void SetAdaptiveWeeklyForecastEnabled(bool enabled) + { + lock (_historyLock) + { + _adaptiveWeeklyForecastEnabled = enabled; + if (enabled) + { + _adaptiveWeeklyUsageStore.Record( + Current.Secondary, + _weeklyHistory, + GetAdaptiveMaximumGap()); + } + } + } + + internal void ClearAdaptiveWeeklyHistory() + { + lock (_historyLock) + { + _adaptiveWeeklyUsageStore.Clear(); + } + } + public Task RefreshAsync() { TaskCompletionSource completion; @@ -145,6 +184,13 @@ internal void RecordHistory(CodexUsageSnapshot snapshot, DateTimeOffset? recorde if (RecordWindowHistory(_weeklyHistory, snapshot.Secondary, snapshot.UpdatedAt, now, now - TimeSpan.FromDays(7))) { _weeklyHistoryStore.Save(_weeklyHistory); + if (_adaptiveWeeklyForecastEnabled) + { + _adaptiveWeeklyUsageStore.Record( + snapshot.Secondary, + _weeklyHistory, + GetAdaptiveMaximumGap()); + } } } } @@ -171,6 +217,9 @@ private static bool RecordWindowHistory( return true; } + private TimeSpan GetAdaptiveMaximumGap() => + TimeSpan.FromTicks(Math.Max(TimeSpan.FromMinutes(5).Ticks, RefreshInterval.Ticks) * 3); + private async Task ExecuteRefreshAsync(TaskCompletionSource completion, CancellationToken cancellationToken) { try diff --git a/CodexUsageDock/Package.appxmanifest b/CodexUsageDock/Package.appxmanifest index 5f788a5..54ff92d 100644 --- a/CodexUsageDock/Package.appxmanifest +++ b/CodexUsageDock/Package.appxmanifest @@ -37,7 +37,8 @@ Description="Codex Usage" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" - Square44x44Logo="Assets\Square44x44Logo.png"> + Square44x44Logo="Assets\Square44x44Logo.png" + AppListEntry="none"> primaryHistory, IReadOnlyList weeklyHistory, - TimeSpan refreshInterval) + TimeSpan refreshInterval, + bool adaptiveWeeklyForecastEnabled = true, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { var dataAvailable = snapshot.Source != UsageDataSource.Unavailable; var maximumSampleAge = TrendFreshness(refreshInterval); @@ -87,8 +91,17 @@ internal static string FormatMainDataJson( dataAvailable, maximumSampleAge, UsageBarPalette.Weekly, - "Not available"); - AddWeeklyTrendData(data, snapshot.Secondary, weeklyTrend, now, TrendMaximumGap(refreshInterval)); + "Not available", + adaptiveWeeklyForecastEnabled ? adaptiveWeeklyHistory : null, + adaptiveWeeklyForecastEnabled); + AddWeeklyTrendData( + data, + snapshot.Secondary, + weeklyHistory, + weeklyTrend, + dataAvailable, + now, + TrendMaximumGap(refreshInterval)); return data.ToJsonString(); } @@ -199,7 +212,9 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi bool dataAvailable, TimeSpan maximumSampleAge, UsageBarPalette palette, - string inactiveMessage) + string inactiveMessage, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, + bool adaptiveWeeklyForecastEnabled = false) { data[$"{prefix}Available"] = window is not null; if (window is null) @@ -233,7 +248,9 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi window.ResetsAt, now, dataAvailable, - maximumSampleAge); + maximumSampleAge, + adaptiveWeeklyForecastEnabled, + adaptiveWeeklyHistory); data[$"{prefix}Projection"] = trend.Message; return trend; } @@ -241,18 +258,29 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi private static void AddWeeklyTrendData( JsonObject data, RateLimitWindow? window, + IReadOnlyList history, TrendAnalysis? trend, + bool dataAvailable, DateTimeOffset now, TimeSpan maximumGap) { data["weeklyTrendAvailable"] = false; - if (window is null || trend is null || trend.HistoryValues is null || trend.History.Length < 2) + if (window is null || trend is null || !dataAvailable) + { + return; + } + + data["weeklyForecastStatus"] = trend.ForecastStatus; + + var windowStartsAt = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); + var chartHistory = GetHistoryInWindow(history, windowStartsAt); + if (chartHistory.Length < 2) { return; } var chart = WeeklyUsageTrendChartRenderer.Create( - trend.History, + chartHistory, window, now, maximumGap, @@ -329,14 +357,16 @@ internal static string FormatTrend( RateLimitWindow? window, DateTimeOffset now, bool dataAvailable, - TimeSpan maximumSampleAge) + TimeSpan maximumSampleAge, + bool adaptiveWeeklyForecastEnabled = false, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { if (window is null) { return string.Empty; } - return $"## {title}\n\n{FormatTrendBody(history, window, now, dataAvailable, maximumSampleAge)}"; + return $"## {title}\n\n{FormatTrendBody(history, window, now, dataAvailable, maximumSampleAge, adaptiveWeeklyForecastEnabled, adaptiveWeeklyHistory)}"; } private static string FormatTrendBody( @@ -344,10 +374,20 @@ private static string FormatTrendBody( RateLimitWindow window, DateTimeOffset now, bool dataAvailable, - TimeSpan maximumSampleAge) + TimeSpan maximumSampleAge, + bool adaptiveWeeklyForecastEnabled = false, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { var windowStartsAt = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); - return FormatTrendBodyForReset(history, windowStartsAt, window.ResetsAt, now, dataAvailable, maximumSampleAge); + return FormatTrendBodyForReset( + history, + windowStartsAt, + window.ResetsAt, + now, + dataAvailable, + maximumSampleAge, + adaptiveWeeklyForecastEnabled, + adaptiveWeeklyHistory); } private static string FormatTrendBodyForReset( @@ -356,9 +396,19 @@ private static string FormatTrendBodyForReset( DateTimeOffset? resetsAt, DateTimeOffset now, bool dataAvailable, - TimeSpan maximumSampleAge) + TimeSpan maximumSampleAge, + bool adaptiveWeeklyForecastEnabled = false, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { - var analysis = AnalyzeTrend(history, windowStartsAt, resetsAt, now, dataAvailable, maximumSampleAge); + var analysis = AnalyzeTrend( + history, + windowStartsAt, + resetsAt, + now, + dataAvailable, + maximumSampleAge, + adaptiveWeeklyForecastEnabled, + adaptiveWeeklyHistory); if (analysis.HistoryValues is null) { return analysis.Message; @@ -374,17 +424,19 @@ private static TrendAnalysis AnalyzeTrend( DateTimeOffset? resetsAt, DateTimeOffset now, bool dataAvailable, - TimeSpan maximumSampleAge) + TimeSpan maximumSampleAge, + bool adaptiveWeeklyForecastEnabled = false, + AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null) { if (!dataAvailable) { - return new([], null, "Projection unavailable until fresh usage data is loaded.", false, null); + return new([], null, "Projection unavailable until fresh usage data is loaded.", false, null, "Forecast unavailable."); } var currentWindow = GetCurrentTrendHistory(history, windowStartsAt); if (currentWindow.Length < 2) { - return new(currentWindow, null, "Projection will appear after another measurement.", false, null); + return new(currentWindow, null, "Projection will appear after another measurement.", false, null, "Forecast: waiting for another measurement."); } var samples = currentWindow.Length <= 5 ? currentWindow : currentWindow.Where((_, index) => index % Math.Max(1, currentWindow.Length / 4) == 0).Take(4).Append(currentWindow[^1]).ToArray(); @@ -395,33 +447,75 @@ private static TrendAnalysis AnalyzeTrend( var consumed = first.RemainingPercent - last.RemainingPercent; if (elapsedMinutes < 2 || consumed <= 0.5) { - return new(currentWindow, values, "No meaningful change yet; projection pending.", false, null); + return new(currentWindow, values, "No meaningful change yet; projection pending.", false, null, "Forecast: waiting for a meaningful change."); } if (now - last.RecordedAt > maximumSampleAge) { - return new(currentWindow, values, "Projection paused because the latest measurement is too old.", false, null); + return new(currentWindow, values, "Projection paused because the latest measurement is too old.", false, null, "Forecast: waiting for a fresh measurement."); } - var minutesToEmpty = last.RemainingPercent / (consumed / elapsedMinutes); - var estimated = last.RecordedAt.AddMinutes(minutesToEmpty); - if (resetsAt is { } reset && estimated >= reset) + var currentRate = consumed / elapsedMinutes; + if (resetsAt is { } reset) { - var remainingAtReset = Math.Max(0, last.RemainingPercent - (consumed / elapsedMinutes) * (reset - last.RecordedAt).TotalMinutes); + AdaptiveWeeklyForecastProjection projection; + if (windowStartsAt is { } start) + { + projection = AdaptiveWeeklyForecast.Project( + last, + start, + reset, + currentRate, + adaptiveWeeklyForecastEnabled, + adaptiveWeeklyHistory); + } + else + { + var estimatedAtCurrentRate = last.RecordedAt.AddMinutes(last.RemainingPercent / currentRate); + var forecast = estimatedAtCurrentRate >= reset + ? new UsageTrendForecast( + reset, + Math.Max(0, last.RemainingPercent - currentRate * (reset - last.RecordedAt).TotalMinutes), + false, + [new UsageTrendForecastPoint(reset, Math.Max(0, last.RemainingPercent - currentRate * (reset - last.RecordedAt).TotalMinutes))]) + : new UsageTrendForecast( + estimatedAtCurrentRate, + 0, + true, + [new UsageTrendForecastPoint(estimatedAtCurrentRate, 0)]); + projection = new AdaptiveWeeklyForecastProjection(forecast, "Forecast: current pace only."); + } + + if (!projection.Forecast.ReachesLimitBeforeReset) + { + return new( + currentWindow, + values, + $"Projected at reset: {projection.Forecast.RemainingPercent:0}% available.", + true, + projection.Forecast, + projection.Status); + } + return new( currentWindow, values, - $"Projected at reset: {remainingAtReset:0}% available.", + $"At the current rate, the limit may be reached around {FormatLimitEstimate(projection.Forecast.EndsAt, now)}.", true, - new UsageTrendForecast(reset, remainingAtReset, false)); + projection.Forecast, + projection.Status); } + var minutesToEmpty = last.RemainingPercent / currentRate; + var estimated = last.RecordedAt.AddMinutes(minutesToEmpty); + return new( currentWindow, values, $"At the current rate, the limit may be reached around {FormatLimitEstimate(estimated, now)}.", true, - new UsageTrendForecast(estimated, 0, true)); + new UsageTrendForecast(estimated, 0, true, [new UsageTrendForecastPoint(estimated, 0)]), + "Forecast: current pace only."); } private sealed record TrendAnalysis( @@ -429,15 +523,14 @@ private sealed record TrendAnalysis( string? HistoryValues, string Message, bool IsEstimate, - UsageTrendForecast? Forecast); + UsageTrendForecast? Forecast, + string ForecastStatus); private static UsageHistoryEntry[] GetCurrentTrendHistory( IReadOnlyList history, DateTimeOffset? windowStartsAt) { - var historyInCurrentWindow = windowStartsAt is { } start - ? history.Where(sample => sample.RecordedAt >= start).ToArray() - : history.ToArray(); + var historyInCurrentWindow = GetHistoryInWindow(history, windowStartsAt); var segmentStart = 0; for (var index = 1; index < historyInCurrentWindow.Length; index++) { @@ -450,6 +543,13 @@ private static UsageHistoryEntry[] GetCurrentTrendHistory( return historyInCurrentWindow[segmentStart..]; } + private static UsageHistoryEntry[] GetHistoryInWindow( + IReadOnlyList history, + DateTimeOffset? windowStartsAt) => + windowStartsAt is { } start + ? history.Where(sample => sample.RecordedAt >= start).ToArray() + : history.ToArray(); + private static string FormatLimitEstimate(DateTimeOffset estimated, DateTimeOffset now) => estimated.ToLocalTime().Date == now.ToLocalTime().Date ? FormatLocalTime(estimated, "HH:mm") @@ -529,7 +629,9 @@ private void UpdatePresentation() _usage.IsLoading, _usage.PrimaryHistory, _usage.WeeklyHistory, - _usage.RefreshInterval); + _usage.RefreshInterval, + _settings.UseAdaptiveWeeklyForecast, + _usage.AdaptiveWeeklyHistory); _details.Body = FormatDetailsBody(snapshot, now); RaiseItemsChanged(0); } diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 2142fae..6937ecb 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -1,5 +1,6 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; namespace CodexUsageDock; @@ -10,6 +11,7 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; private const string ShowResetTimeKey = "showResetTime"; private const string RefreshIntervalKey = "refreshInterval"; + private const string UseAdaptiveWeeklyForecastKey = "useAdaptiveWeeklyForecast"; private readonly Settings _settings = new(); public CodexUsageDockSettingsPage() @@ -38,6 +40,11 @@ public CodexUsageDockSettingsPage() Label = "Show reset time", Description = "Show the next reset time below each usage limit.", }); + _settings.Add(new ToggleSetting(UseAdaptiveWeeklyForecastKey, true) + { + Label = "Use adaptive weekly forecast", + Description = "Blend the current pace with up to eight local weekly cycles. Turning this off pauses learning and keeps saved history.", + }); _settings.Add(new ChoiceSetSetting( RefreshIntervalKey, [ @@ -49,11 +56,31 @@ public CodexUsageDockSettingsPage() Label = "Refresh interval", Description = "How often the extension refreshes local Codex usage data.", }); + var clearHistory = new ConfirmableCommand( + new AnonymousCommand(() => ClearAdaptiveHistoryRequested?.Invoke(this, EventArgs.Empty)) + { + Result = CommandResult.KeepOpen(), + }, + "Delete learned forecast history?", + "This permanently removes local adaptive weekly forecast history from this device.", + () => true) + { + Name = "Delete learned forecast history", + }; + Commands = + [ + new CommandContextItem(clearHistory) + { + Title = "Delete learned forecast history", + }, + ]; _settings.SettingsChanged += OnSettingsChanged; } public event EventHandler? Changed; + public event EventHandler? ClearAdaptiveHistoryRequested; + public bool ShowFiveHourLimit => _settings.GetSetting(ShowFiveHourLimitKey); public bool ShowWeeklyLimit => _settings.GetSetting(ShowWeeklyLimitKey); @@ -62,6 +89,8 @@ public CodexUsageDockSettingsPage() public bool ShowResetTime => _settings.GetSetting(ShowResetTimeKey); + public bool UseAdaptiveWeeklyForecast => _settings.GetSetting(UseAdaptiveWeeklyForecastKey); + public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); public override IContent[] GetContent() => _settings.ToContent(); diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index a51700c..b922a54 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -364,7 +364,7 @@ internal static class UsageDashboardCard }, { "type": "TextBlock", - "text": "Solid: remaining allowance (%) · dashed: forecast · bars: observed daily use (%)", + "text": "Solid: sampled allowance (%) · dashed: forecast · bars: observed calendar-day use (%)", "isSubtle": true, "spacing": "none", "wrap": true @@ -375,6 +375,13 @@ internal static class UsageDashboardCard "altText": "${weeklyTrendChartAlt}", "size": "stretch", "spacing": "small" + }, + { + "type": "TextBlock", + "text": "${weeklyForecastStatus}", + "isSubtle": true, + "spacing": "small", + "wrap": true } ] } diff --git a/CodexUsageDock/UsageData.cs b/CodexUsageDock/UsageData.cs index 9acc19d..dd91505 100644 --- a/CodexUsageDock/UsageData.cs +++ b/CodexUsageDock/UsageData.cs @@ -72,10 +72,13 @@ internal sealed record RateLimitResetCredits(int AvailableCount, IReadOnlyList? Points = null); internal enum UsageDataSource { diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index 212ddfd..3655c9a 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -61,6 +61,7 @@ internal static class WeeklyUsageTrendChartRenderer ['X'] = "101101010101101", ['Y'] = "101101010010010", ['Z'] = "111001010100111", + [' '] = "000000000000000", ['%'] = "101001010100101", ['?'] = "111001010000010", }; @@ -71,7 +72,8 @@ internal static class WeeklyUsageTrendChartRenderer DateTimeOffset now, TimeSpan maximumGap, UsageTrendForecast? forecast, - CultureInfo? culture = null) + CultureInfo? culture = null, + TimeZoneInfo? timeZone = null) { if (window.WindowMinutes <= 0) { @@ -86,15 +88,19 @@ internal static class WeeklyUsageTrendChartRenderer } var displayCulture = culture ?? CultureInfo.CurrentCulture; + var displayTimeZone = timeZone ?? TimeZoneInfo.Local; 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(); + var dailyUse = CreateCalendarDays(windowStart, window.ResetsAt, effectiveNow, displayTimeZone); + var calendarScale = new CalendarDayScale(dailyUse); + var forecastSegments = SplitAtGapsOrQuotaIncreases(samples, maximumGap); + var renderedSegments = DownsampleSegments(SplitAtQuotaIncreases(samples), windowStart, window.ResetsAt); + CalculateDailyUse(samples, dailyUse, effectiveNow, maximumGap); + var latestSegment = forecastSegments.LastOrDefault(); var forecastSegment = latestSegment is { Length: >= 2 } ? latestSegment : null; var usableForecast = forecastSegment is not null && forecast is { } candidate && candidate.EndsAt > forecastSegment[^1].RecordedAt ? candidate @@ -102,14 +108,16 @@ internal static class WeeklyUsageTrendChartRenderer var document = CreateDocument(); AddTrendGrid(document); - AddDailyUseBars(document, dailyUse, displayCulture); - AddNowMarker(document, windowStart, window.ResetsAt, effectiveNow); - AddObservedLines(document, observedSegments, windowStart, window.ResetsAt); - AddForecastLine(document, forecastSegment, usableForecast, windowStart, window.ResetsAt); + AddCalendarDayGrid(document, dailyUse, calendarScale); + AddDailyUseBars(document, dailyUse, calendarScale, displayCulture); + AddResetMarkers(document, windowStart, window.ResetsAt, calendarScale); + AddNowMarker(document, windowStart, window.ResetsAt, effectiveNow, calendarScale); + AddObservedLines(document, renderedSegments, calendarScale); + AddForecastLine(document, forecastSegment, usableForecast, window.ResetsAt, calendarScale); var first = samples[0]; var last = samples[^1]; - var altText = FormatAltText(first, last, samples.Length, usableForecast, windowStart, window.ResetsAt, dailyUse, displayCulture); + var altText = FormatAltText(first, last, samples.Length, usableForecast, windowStart, window.ResetsAt, dailyUse, displayCulture, displayTimeZone); return new WeeklyUsageTrendChart(UsageDashboardCard.CreateSvgImageUrl(document), altText); } @@ -210,16 +218,31 @@ private static UsageHistoryEntry[] Downsample( .ToArray(); } - private static List SplitAtGaps( + private static List SplitAtGapsOrQuotaIncreases( UsageHistoryEntry[] samples, TimeSpan maximumGap) { var gap = maximumGap > TimeSpan.Zero ? maximumGap : TimeSpan.FromMinutes(5); + return SplitAtDiscontinuities( + samples, + (previous, current) => current.RecordedAt - previous.RecordedAt > gap || + current.RemainingPercent > previous.RemainingPercent); + } + + private static List SplitAtQuotaIncreases(UsageHistoryEntry[] samples) => + SplitAtDiscontinuities( + samples, + (previous, current) => current.RemainingPercent > previous.RemainingPercent); + + private static List SplitAtDiscontinuities( + UsageHistoryEntry[] samples, + Func startsNewSegment) + { var segments = new List(); var current = new List(); foreach (var sample in samples) { - if (current.Count > 0 && sample.RecordedAt - current[^1].RecordedAt > gap) + if (current.Count > 0 && startsNewSegment(current[^1], sample)) { segments.Add([.. current]); current.Clear(); @@ -236,24 +259,61 @@ private static List SplitAtGaps( return segments; } - private static DailyUsage[] CalculateDailyUse( - UsageHistoryEntry[] samples, + private static DailyUsage[] CreateCalendarDays( DateTimeOffset windowStart, DateTimeOffset windowEnd, DateTimeOffset now, + TimeZoneInfo timeZone) + { + var firstDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(windowStart, timeZone).Date); + var lastDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(windowEnd, timeZone).Date); + var days = new List(); + for (var date = firstDate; date <= lastDate; date = date.AddDays(1)) + { + var localStart = AtLocalMidnight(date, timeZone); + var localEnd = AtLocalMidnight(date.AddDays(1), timeZone); + var start = localStart > windowStart ? localStart : windowStart; + var end = localEnd < windowEnd ? localEnd : windowEnd; + if (end > start) + { + days.Add(new DailyUsage( + date, + localStart, + localEnd, + start, + end, + start != localStart || end != localEnd, + now >= start && now < end)); + } + } + + return [.. days]; + } + + private static DateTimeOffset AtLocalMidnight(DateOnly date, TimeZoneInfo timeZone) + { + var local = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified); + while (timeZone.IsInvalidTime(local)) + { + local = local.AddMinutes(30); + } + + return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, timeZone), TimeSpan.Zero); + } + + private static void CalculateDailyUse( + UsageHistoryEntry[] samples, + IReadOnlyList dailyUse, + 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 rangeStart = previous.RecordedAt; var rangeEnd = current.RecordedAt > now ? now : current.RecordedAt; if (rangeEnd <= rangeStart || rangeEnd - rangeStart > gap) { @@ -262,10 +322,10 @@ private static DailyUsage[] CalculateDailyUse( var observedUse = Math.Max(0, previous.RemainingPercent - current.RemainingPercent); var rangeDuration = rangeEnd - rangeStart; - for (var dayIndex = 0; dayIndex < dailyUse.Length; dayIndex++) + for (var dayIndex = 0; dayIndex < dailyUse.Count; dayIndex++) { var dayStart = dailyUse[dayIndex].Start; - var dayEnd = dayStart.AddDays(1); + var dayEnd = dailyUse[dayIndex].End; var overlapStart = rangeStart > dayStart ? rangeStart : dayStart; var overlapEnd = rangeEnd < dayEnd ? rangeEnd : dayEnd; if (overlapEnd <= overlapStart) @@ -277,8 +337,30 @@ private static DailyUsage[] CalculateDailyUse( dailyUse[dayIndex].ConsumedPercent += observedUse * (overlapEnd - overlapStart).TotalMilliseconds / rangeDuration.TotalMilliseconds; } } + } - return dailyUse; + private static void AddCalendarDayGrid( + XElement document, + IReadOnlyList dailyUse, + CalendarDayScale calendarScale) + { + for (var index = 1; index < dailyUse.Count; index++) + { + var day = dailyUse[index]; + var x = calendarScale.GetDayStartX(index); + document.Add( + new XElement( + Svg + "line", + new XAttribute("x1", Format(x)), + new XAttribute("x2", Format(x)), + new XAttribute("y1", TrendTop), + new XAttribute("y2", TrendBottom), + new XAttribute("stroke", "#7A7A7A"), + new XAttribute("stroke-opacity", "0.25"), + new XAttribute("stroke-width", "1"), + new XAttribute("data-grid", "calendar-day"), + new XAttribute("data-date", day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)))); + } } private static void AddTrendGrid(XElement document) @@ -395,14 +477,15 @@ private static bool CanRenderBitmapLabel(string label) return hasGlyph; } - private static string FormatWeekdayLabel(DateTimeOffset day, CultureInfo culture) + private static string FormatCalendarDayLabel(DateOnly date, CultureInfo culture) { - var label = culture.DateTimeFormat.GetAbbreviatedDayName(day.ToLocalTime().DayOfWeek) + var label = culture.DateTimeFormat.GetAbbreviatedDayName(date.DayOfWeek) .Trim() .TrimEnd('.'); - return CanRenderBitmapLabel(label) + var weekday = CanRenderBitmapLabel(label) ? label - : CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(day.ToLocalTime().DayOfWeek); + : CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(date.DayOfWeek); + return $"{weekday} {date.Day}"; } private static double MeasureBitmapLabel(string label) => @@ -410,18 +493,47 @@ private static double MeasureBitmapLabel(string label) => private static double BitmapLabelHeight => BitmapGlyphRows * BitmapCellSize; + private static void AddResetMarkers( + XElement document, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + CalendarDayScale calendarScale) + { + foreach (var (x, edge) in new[] + { + (calendarScale.GetX(windowStart), "start"), + (calendarScale.GetX(windowEnd), "end"), + }) + { + document.Add( + new XElement( + Svg + "line", + new XAttribute("x1", Format(x)), + new XAttribute("x2", Format(x)), + new XAttribute("y1", TrendTop), + new XAttribute("y2", TrendBottom), + new XAttribute("stroke", "#C8C8C8"), + new XAttribute("stroke-opacity", "0.6"), + new XAttribute("stroke-width", "1"), + new XAttribute("stroke-dasharray", "2 2"), + new XAttribute("data-marker", $"reset-{edge}"))); + AddBitmapLabel(document, "RESET", x, 0, BitmapLabelAlignment.Center, "marker"); + } + } + private static void AddNowMarker( XElement document, DateTimeOffset windowStart, DateTimeOffset windowEnd, - DateTimeOffset now) + DateTimeOffset now, + CalendarDayScale calendarScale) { if (now <= windowStart || now >= windowEnd) { return; } - var x = GetX(now, windowStart, windowEnd); + var x = calendarScale.GetX(now); document.Add( new XElement( Svg + "line", @@ -431,14 +543,17 @@ private static void AddNowMarker( new XAttribute("y2", TrendBottom), new XAttribute("stroke", "#C8C8C8"), new XAttribute("stroke-opacity", "0.45"), - new XAttribute("stroke-width", "1"))); + new XAttribute("stroke-width", "1"), + new XAttribute("data-marker", "now"))); + var labelWidth = MeasureBitmapLabel("NOW"); + var labelX = Math.Clamp(x, Left + labelWidth / 2, UsageDashboardCard.BarWidth - Right - labelWidth / 2); + AddBitmapLabel(document, "NOW", labelX, 0, BitmapLabelAlignment.Center, "marker"); } private static void AddObservedLines( XElement document, IReadOnlyList segments, - DateTimeOffset windowStart, - DateTimeOffset windowEnd) + CalendarDayScale calendarScale) { var latest = segments.LastOrDefault(segment => segment.Length > 0)?[^1]; foreach (var segment in segments.Where(segment => segment.Length >= 2)) @@ -446,7 +561,7 @@ private static void AddObservedLines( document.Add( new XElement( Svg + "polyline", - new XAttribute("points", FormatPoints(segment, windowStart, windowEnd)), + new XAttribute("points", FormatPoints(segment, calendarScale)), new XAttribute("fill", "none"), new XAttribute("stroke", "#5C9EFA"), new XAttribute("stroke-width", "2"), @@ -462,7 +577,7 @@ private static void AddObservedLines( document.Add( new XElement( Svg + "circle", - new XAttribute("cx", Format(GetX(sample.RecordedAt, windowStart, windowEnd))), + new XAttribute("cx", Format(calendarScale.GetX(sample.RecordedAt))), new XAttribute("cy", Format(GetTrendY(sample.RemainingPercent))), new XAttribute("r", "1.5"), new XAttribute("fill", "#5C9EFA"))); @@ -473,7 +588,7 @@ private static void AddObservedLines( document.Add( new XElement( Svg + "circle", - new XAttribute("cx", Format(GetX(latest.RecordedAt, windowStart, windowEnd))), + new XAttribute("cx", Format(calendarScale.GetX(latest.RecordedAt))), new XAttribute("cy", Format(GetTrendY(latest.RemainingPercent))), new XAttribute("r", "3"), new XAttribute("fill", "#5C9EFA"), @@ -486,8 +601,8 @@ private static void AddForecastLine( XElement document, UsageHistoryEntry[]? latestSegment, UsageTrendForecast? forecast, - DateTimeOffset windowStart, - DateTimeOffset windowEnd) + DateTimeOffset windowEnd, + CalendarDayScale calendarScale) { if (latestSegment is null || forecast is null) { @@ -501,11 +616,20 @@ private static void AddForecastLine( return; } - var points = new List + var points = new List { last }; + if (forecast.Points is { Count: > 0 }) { - last, - new(end, forecast.RemainingPercent), - }; + points.AddRange( + forecast.Points + .Where(point => point.RecordedAt > last.RecordedAt && point.RecordedAt <= end) + .Select(point => new UsageHistoryEntry(point.RecordedAt, point.RemainingPercent))); + } + + if (points.Count == 1 || points[^1].RecordedAt < end) + { + points.Add(new UsageHistoryEntry(end, forecast.RemainingPercent)); + } + if (forecast.ReachesLimitBeforeReset && end < windowEnd) { points.Add(new UsageHistoryEntry(windowEnd, 0)); @@ -514,7 +638,7 @@ private static void AddForecastLine( document.Add( new XElement( Svg + "polyline", - new XAttribute("points", FormatPoints(points, windowStart, windowEnd)), + new XAttribute("points", FormatPoints(points, calendarScale)), new XAttribute("fill", "none"), new XAttribute("stroke", "#5C9EFA"), new XAttribute("stroke-width", "2"), @@ -527,17 +651,18 @@ private static void AddForecastLine( private static void AddDailyUseBars( XElement document, IReadOnlyList dailyUse, + CalendarDayScale calendarScale, CultureInfo culture) { - var chartWidth = UsageDashboardCard.BarWidth - Left - Right; - var dayWidth = chartWidth / dailyUse.Count; var baseline = TrendBottom; 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); + var dayLeft = calendarScale.GetDayStartX(index); + var dayRight = calendarScale.GetDayEndX(index); + var x = dayLeft + 3; + var width = Math.Max(2, dayRight - dayLeft - 6); if (day.HasObservation && day.ConsumedPercent > 0) { var height = Math.Max(1, Math.Clamp(day.ConsumedPercent, 0, 100) / 100 * TrendHeight); @@ -551,13 +676,19 @@ private static void AddDailyUseBars( new XAttribute("rx", "1.5"), new XAttribute("fill", "#7A7A7A"), new XAttribute("fill-opacity", "0.45"), - new XAttribute("data-series", "daily-use"))); + new XAttribute("data-series", "daily-use"), + new XAttribute("data-date", day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + 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-width", day.IsCurrent ? "1" : "0"))); } AddBitmapLabel( document, - FormatWeekdayLabel(day.Start, culture), - x + width / 2, + FormatCalendarDayLabel(day.Date, culture), + (dayLeft + dayRight) / 2, DayLabelTop, BitmapLabelAlignment.Center, "horizontal"); @@ -572,40 +703,28 @@ private static string FormatAltText( DateTimeOffset windowStart, DateTimeOffset windowEnd, IReadOnlyList dailyUse, - CultureInfo culture) + CultureInfo culture, + TimeZoneInfo timeZone) { - var period = $"{windowStart.ToLocalTime().ToString("ddd d MMM HH:mm", culture)} to {windowEnd.ToLocalTime().ToString("ddd d MMM HH:mm", culture)}"; + var period = $"{TimeZoneInfo.ConvertTime(windowStart, timeZone).ToString("ddd d MMM HH:mm", culture)} to {TimeZoneInfo.ConvertTime(windowEnd, timeZone).ToString("ddd d MMM HH:mm", culture)}"; var daily = dailyUse.Any(day => day.HasObservation) - ? "Daily bars show observed quota consumption." - : "No continuous measurements are available for daily consumption bars."; + ? "Calendar-day bars show observed quota consumption; the first and last days can be partial, and the current day is measured through now." + : "No continuous measurements are available for calendar-day consumption bars."; var forecastText = forecast switch { - { ReachesLimitBeforeReset: true } => $" Forecast reaches the limit around {forecast.EndsAt.ToLocalTime().ToString("ddd d MMM HH:mm", culture)}.", + { ReachesLimitBeforeReset: true } => $" Forecast reaches the limit around {TimeZoneInfo.ConvertTime(forecast.EndsAt, timeZone).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 percentages from 0% to 100% for both remaining allowance and daily-use bars; horizontal labels are quota-window weekdays. Solid line and points are observed; dashed line is forecast.{forecastText} {daily}"; + return $"Weekly quota trend from {period}. Remaining allowance changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observations. The vertical scale is percentages from 0% to 100% for both remaining allowance and daily-use bars; horizontal labels are local calendar dates, and reset markers bound the quota window. Solid line connects sampled values across measurement gaps; line breaks mark allowance increases or resets; dashed line is forecast.{forecastText} {daily}"; } private static string FormatPoints( IEnumerable points, - DateTimeOffset windowStart, - DateTimeOffset windowEnd) => + CalendarDayScale calendarScale) => 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); - } + points.Select(point => $"{Format(calendarScale.GetX(point.RecordedAt))},{Format(GetTrendY(point.RemainingPercent))}")); private static double GetTrendY(double remainingPercent) => TrendTop + (100 - Math.Clamp(remainingPercent, 0, 100)) / 100 * TrendHeight; @@ -619,12 +738,68 @@ private enum BitmapLabelAlignment End, } - private sealed class DailyUsage(DateTimeOffset start) + private sealed class DailyUsage( + DateOnly date, + DateTimeOffset calendarStart, + DateTimeOffset calendarEnd, + DateTimeOffset start, + DateTimeOffset end, + bool isPartial, + bool isCurrent) { + public DateOnly Date { get; } = date; + + public DateTimeOffset CalendarStart { get; } = calendarStart; + + public DateTimeOffset CalendarEnd { get; } = calendarEnd; + public DateTimeOffset Start { get; } = start; + public DateTimeOffset End { get; } = end; + + public bool IsPartial { get; } = isPartial; + + public bool IsCurrent { get; } = isCurrent; + public double ConsumedPercent { get; set; } public bool HasObservation { get; set; } } + + private sealed class CalendarDayScale(IReadOnlyList days) + { + private readonly IReadOnlyList _days = days; + + public double GetX(DateTimeOffset instant) + { + if (_days.Count == 0 || instant <= _days[0].CalendarStart) + { + return Left; + } + + for (var index = 0; index < _days.Count; index++) + { + var day = _days[index]; + if (instant < day.CalendarEnd || index == _days.Count - 1) + { + var duration = day.CalendarEnd - day.CalendarStart; + var elapsed = instant - day.CalendarStart; + var positionWithinDay = duration > TimeSpan.Zero + ? Math.Clamp(elapsed.TotalMilliseconds / duration.TotalMilliseconds, 0, 1) + : 0; + return GetDayStartX(index) + positionWithinDay * DayWidth; + } + } + + return UsageDashboardCard.BarWidth - Right; + } + + public double GetDayStartX(int index) => Left + index * DayWidth; + + public double GetDayEndX(int index) => Left + (index + 1) * DayWidth; + + private double DayWidth => _days.Count > 0 + ? (UsageDashboardCard.BarWidth - Left - Right) / _days.Count + : 0; + } } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0e3c7ac..4de9cdb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,10 +84,11 @@ Complete every row on a clean x64 environment and a separate clean ARM64 environ | Update | Required | Required | The previous supported Store version updates in place; Command Palette discovers the new version and settings remain intact. | | 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. | +| Start-menu visibility | Required | Required | Codex Usage does not appear as a standalone app in Start; it is activated only by Command Palette through its packaged COM registration. | | Discovery and reload | Required | Required | After **Reload Command Palette Extension**, **Codex Usage** appears once under **Settings > Extensions** and can be enabled. | -| Details page | Required | Required | Opening **Codex Usage** shows five-hour and weekly quota summaries that compare allowance used with elapsed window time and include a projection for each active window. The weekly view 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. | +| Details page | Required | Required | Opening **Codex Usage** shows five-hour and weekly quota summaries that compare allowance used with elapsed window time and include a projection for each active window. The weekly view spans the quota window: continuous observed allowance is solid, a fresh projection is dashed, bars show only observed consumption per local calendar day, dated weekday labels include the partial first and last reset-boundary days, and reset/current-time markers clarify the timeline. When adaptive forecasting has history, its dashed line may change at six-hour boundaries and the status line identifies the local-history depth; disabled or immature history falls back to current pace. Resets, credits, account status, and source appear in the native Details pane. Verify pace labels, inactive/projection fallback states, chart gap handling, visible 0–100% and calendar-date axes, partial-day placement, semantic status colors, narrow and wide window sizes, light/dark themes, and high contrast; manual refresh must update both areas without clipping or freezing Command Palette. | | 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. | +| 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 while retaining 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. | | Local fallback | Required | Required | In an isolated test account with local session metadata but no launchable standalone CLI, fallback data appears and is identified as local session data. Do not rename or delete a real CLI installation to create this state. | | No-data failure | Required | Required | In an isolated account with neither a CLI nor session data, the extension shows a bounded unavailable/error state and does not crash or loop. | @@ -120,6 +121,7 @@ The command builds from an isolated manifest copy with the four-part MSIX versio - the complete generated asset set and its required pixel dimensions; - the complete app-local managed application and .NET runtime layout, including self-contained `includedFrameworks` metadata; - packaged COM and `com.microsoft.commandpalette` registrations; +- a hidden Start-menu entry point, because the executable is activated as a Command Palette COM server rather than as a standalone app; - one bundle inside the final `.msixupload`. Successful output contains only: @@ -145,7 +147,7 @@ The packages inside `.msixupload` are intentionally unsigned. Partner Center sig 2. Set the project `` to the intended release version, commit it, and run **Store package** manually. 3. Download and extract the corresponding `CodexUsageDock--store` Actions artifact. 4. In Partner Center, open existing product `9NFCPJXQG9FG` and upload the matching `.msixupload` to a new submission. -5. Complete the required Store listing and testing notes, then submit for certification. +5. Complete the required Store listing and testing notes. State that this package is a PowerToys Command Palette extension, does not expose a standalone Start-menu app, and must be tested by enabling **Codex Usage** under **Command Palette > Settings > Extensions**, running **Reload Command Palette Extension**, and opening **Codex Usage** inside Command Palette. Then submit for certification. 6. Stop on rejection and address the reported issue in a new package version. Do not use a self-signed or alternate distribution fallback. 7. After the Store listing is publicly installable, verify x64 and ARM64 installation, removal, Command Palette discovery, and live Dock usage. 8. Update `README.md` with `https://apps.microsoft.com/detail/9NFCPJXQG9FG`. diff --git a/PRIVACY.md b/PRIVACY.md index 8767841..063c2fe 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: July 14, 2026 +Last updated: July 17, 2026 Codex Usage Dock is a local Windows extension for PowerToys Command Palette. It displays Codex usage limits, earned resets, reset expiry times, and available credits in the Command Palette Dock. @@ -16,7 +16,7 @@ Communication initiated by the extension is limited to the local Codex app-serve ## Data storage -The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. This file contains no account, session, prompt, or message content and is never transmitted. +The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. When the adaptive weekly forecast is enabled, it also stores at most eight aggregated quota-cycle profiles: total observed duration and consumption, plus six-hour usage buckets relative to the reset. These files contain no account, session, prompt, or message content and are never transmitted. Users can pause learning while keeping those profiles, or delete the learned profiles from Codex Usage settings. ## Permissions diff --git a/README.md b/README.md index 31a173c..63a478b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ 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; +- an optional adaptive weekly forecast that keeps the current pace dominant and gradually blends in up to eight local quota cycles and six-hour usage patterns; +- a weekly trend chart with sampled remaining allowance, a dashed projection that can follow six-hour adaptive forecast points, equal-width local calendar-day columns, dated weekday labels, partial reset-boundary days, and a shared 0–100% vertical scale; the line connects samples across measurement gaps but breaks at allowance increases, while the forecast uses only the latest post-increase segment; - the number of available earned resets and their expiry times; - the remaining credits balance when Codex provides it. @@ -29,6 +30,8 @@ Microsoft Store publication for version `0.5.0` is currently pending. That versi GitHub Actions artifacts are inputs for Microsoft Store certification, not public installers. Do not distribute or install them directly. +Codex Usage Dock is activated inside PowerToys Command Palette and intentionally has no standalone Start-menu entry. + ## Add Codex Usage to the Dock 1. Open Command Palette settings. @@ -42,7 +45,7 @@ The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10. ## Customize the Dock -Open Command Palette and select **Codex Usage settings** to choose which usage entries appear in the Dock. You can independently show or hide the five-hour limit, weekly limit, and resets and credits, choose whether usage entries show their reset time, and set the local data refresh interval to 1, 5, or 15 minutes. Command Palette stores these settings for the current user. +Open Command Palette and select **Codex Usage settings** to choose which usage entries appear in the Dock. You can independently show or hide the five-hour limit, weekly limit, and resets and credits, choose whether usage entries show their reset time, set the local data refresh interval to 1, 5, or 15 minutes, and enable or pause the adaptive weekly forecast. Pausing the forecast keeps its learned local history; **Delete learned forecast history** asks for confirmation before permanently clearing it. Command Palette stores these settings for the current user. ## Update @@ -67,7 +70,7 @@ Microsoft Store product `9NFCPJXQG9FG` is the only production and update channel ## Privacy -The extension runs locally. It talks to the standalone Codex CLI app-server and may read local Codex session metadata for its fallback path. It keeps a rolling seven-day weekly usage trend on the device and does not send usage information to a separate service. +The extension runs locally. It talks to the standalone Codex CLI app-server and may read local Codex session metadata for its fallback path. It keeps a rolling seven-day weekly usage trend and up to eight aggregated local weekly forecast profiles on the device, and does not send usage information to a separate service. See the full [Privacy Policy](PRIVACY.md). diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 index e5f5efd..d60b32b 100644 --- a/scripts/build-release.ps1 +++ b/scripts/build-release.ps1 @@ -322,6 +322,10 @@ function Assert-CommandPaletteRegistrations { throw 'MSIX must declare MaxVersionTested 10.0.26100.0.' } + if (-not $visualElements -or [string]$visualElements.GetAttribute('AppListEntry') -cne 'none') { + throw 'The Command Palette extension entry point must be hidden from the Start menu with AppListEntry="none".' + } + $visualAssetAttributes = [ordered]@{ Square44x44Logo = @($visualElements, 'Assets\Square44x44Logo.png') Square150x150Logo = @($visualElements, 'Assets\Square150x150Logo.png') diff --git a/scripts/test-integration.ps1 b/scripts/test-integration.ps1 index d9f10de..b29270b 100644 --- a/scripts/test-integration.ps1 +++ b/scripts/test-integration.ps1 @@ -164,6 +164,7 @@ function Test-CommandPaletteManifest { Capabilities = @($manifest.SelectNodes("/f:Package/f:Capabilities/*", $namespaces) | ForEach-Object { "$(Get-AttributeValue -Node $_ -Name 'Name')|$($_.NamespaceURI)" } | Sort-Object) ApplicationExecutable = $applicationExecutable ComExecutable = $comExecutable + AppListEntry = Get-AttributeValue -Node $visualElements -Name "AppListEntry" Square44x44Logo = Get-AttributeValue -Node $visualElements -Name "Square44x44Logo" Square150x150Logo = Get-AttributeValue -Node $visualElements -Name "Square150x150Logo" Square71x71Logo = Get-AttributeValue -Node $defaultTile -Name "Square71x71Logo" @@ -211,6 +212,11 @@ function Test-CommandPaletteManifest { Add-Failure "$Label must target Windows MaxVersionTested 10.0.26100.0." } + if ($releaseFacts['AppListEntry'] -cne 'none') { + $hasRequiredReleaseFacts = $false + Add-Failure "$Label must hide the Command Palette extension entry point from the Start menu." + } + $capabilities = @($releaseFacts['Capabilities']) if ($capabilities.Count -ne 1 -or $capabilities[0] -notlike 'runFullTrust|*') { $hasRequiredReleaseFacts = $false @@ -452,7 +458,7 @@ if ($null -ne $sourceFacts -and $null -ne $generatedFacts) { } if ($sourceFacts.ReleaseFactsJson -cne $generatedFacts.ReleaseFactsJson) { - Add-Failure "Source and generated manifests differ in publisher, MaxVersionTested, capabilities, or tile references. Rebuild the selected artifact." + Add-Failure "Source and generated manifests differ in publisher, MaxVersionTested, Start-menu visibility, capabilities, or tile references. Rebuild the selected artifact." } }