From 002d38534f54fce012545f2477486347d7bc325c Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:27:11 +0700 Subject: [PATCH 1/2] Port upstream 0.70.0: record remaining-quota burndown for Codex and Claude --- .../src-tauri/src/commands/bridge.rs | 103 +++ .../src/components/MenuCardDetails.tsx | 4 + .../components/QuotaBurndownChart.test.tsx | 98 +++ .../src/components/QuotaBurndownChart.tsx | 123 +++ apps/desktop-tauri/src/i18n/keys.ts | 4 + apps/desktop-tauri/src/styles.css | 47 + apps/desktop-tauri/src/types/bridge.ts | 18 + rust/src/core/mod.rs | 5 + rust/src/core/quota_burndown.rs | 818 ++++++++++++++++++ rust/src/core/session_equivalent_forecast.rs | 153 +++- rust/src/locale.rs | 4 + rust/src/locale/en-US.ftl | 3 + 12 files changed, 1375 insertions(+), 5 deletions(-) create mode 100644 apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx create mode 100644 apps/desktop-tauri/src/components/QuotaBurndownChart.tsx create mode 100644 rust/src/core/quota_burndown.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a6..4c242d1d3e6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -161,6 +161,29 @@ pub struct PaceSnapshot { pub actual_used_percent: f64, } +/// One burndown chart point (RFC 3339 capture time + remaining percent). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaBurndownPointSnapshot { + pub captured_at: String, + pub remaining_percent: f64, +} + +/// Recorded remaining-quota burndown for one series (session / weekly), +/// upstream 0.70.0 #4085. `captured_at` of the last sample drives the +/// capture-age caption; the chart is empty when there is no current window. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaBurndownSnapshot { + /// `session` or `weekly`. + pub series: String, + pub window_minutes: u32, + pub start: String, + pub reset: String, + pub samples: Vec, + pub ideal: [QuotaBurndownPointSnapshot; 2], +} + /// Session-equivalent weekly forecast for Claude/Codex menu secondary line. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -269,6 +292,10 @@ pub struct ProviderUsageSnapshot { pub wayfinder_usage: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub session_equivalent_forecast: Option, + /// Recorded remaining-quota burndown for the selected window + /// (upstream 0.70.0 #4085); Codex and Claude only. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub quota_burndown: Option, } fn default_display_name() -> String { @@ -376,6 +403,12 @@ impl ProviderUsageSnapshot { &usage.primary, usage.secondary.as_ref(), ); + let quota_burndown = quota_burndown_for( + id, + account_key.as_deref(), + &usage.primary, + usage.secondary.as_ref(), + ); Self { provider_id: id.cli_name().to_string(), @@ -493,6 +526,7 @@ impl ProviderUsageSnapshot { fetch_duration_ms: None, wayfinder_usage: result.wayfinder_usage.clone(), session_equivalent_forecast, + quota_burndown, } } @@ -543,6 +577,7 @@ impl ProviderUsageSnapshot { fetch_duration_ms: None, wayfinder_usage: None, session_equivalent_forecast: None, + quota_burndown: None, } } } @@ -615,6 +650,74 @@ fn session_equivalent_forecast_for( }) } +/// Build the recorded burndown for the live window (session or weekly lane) +/// from the persisted history; `None` when the window is expired or unknown. +/// Codex and Claude only, mirroring the forecast gate. +fn quota_burndown_for( + id: ProviderId, + account_key: Option<&str>, + session: &RateWindow, + weekly: Option<&RateWindow>, +) -> Option { + if !matches!(id, ProviderId::Claude | ProviderId::Codex) { + return None; + } + let now = chrono::Utc::now(); + let provider_id = id.cli_name(); + // Persisted history feeds the chart; loading merges it into the + // process-local store once per scope. + let histories = codexbar::core::load_persisted_history(provider_id, account_key); + + let live = |series: &str, window: &RateWindow| -> Option { + let model = codexbar::core::QuotaBurndownModel::build( + &series_entries(&histories, series), + window, + now, + )?; + Some(QuotaBurndownSnapshot { + series: series.to_string(), + window_minutes: window.window_minutes?, + start: model.start.to_rfc3339(), + reset: model.reset.to_rfc3339(), + samples: model + .samples + .iter() + .map(|sample| QuotaBurndownPointSnapshot { + captured_at: sample.captured_at.to_rfc3339(), + remaining_percent: sample.remaining_percent, + }) + .collect(), + ideal: [ + QuotaBurndownPointSnapshot { + captured_at: model.ideal[0].captured_at.to_rfc3339(), + remaining_percent: model.ideal[0].remaining_percent, + }, + QuotaBurndownPointSnapshot { + captured_at: model.ideal[1].captured_at.to_rfc3339(), + remaining_percent: model.ideal[1].remaining_percent, + }, + ], + }) + }; + + // Upstream prefers the shortest current window (session over weekly). + live("session", session).or_else(|| weekly.and_then(|w| live("weekly", w))) +} + +fn series_entries( + histories: &[codexbar::core::PlanUtilizationSeriesHistory], + series: &str, +) -> Vec { + histories + .iter() + .find(|history| match series { + "session" => history.name == codexbar::core::PlanUtilizationSeriesName::Session, + _ => history.name == codexbar::core::PlanUtilizationSeriesName::Weekly, + }) + .map(|history| history.entries.clone()) + .unwrap_or_default() +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BootstrapState { diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910e..bc2650d72fd 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -23,6 +23,7 @@ import { paceCategory } from "../surfaces/tray/paceCategory"; import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; import { InventoryItemRow } from "./InventoryRows"; import { QuotaWindowHistory } from "./QuotaWindowHistory"; +import QuotaBurndownChart from "./QuotaBurndownChart"; import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; import PaceDetailsChart from "./PaceDetailsChart"; @@ -778,6 +779,9 @@ export default function MenuCardDetails({ {hasQuotaWindowHistory && ( )} + {provider.quotaBurndown && ( + + )} )} diff --git a/apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx b/apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx new file mode 100644 index 00000000000..94398def356 --- /dev/null +++ b/apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import QuotaBurndownChart from "./QuotaBurndownChart"; +import type { QuotaBurndownSnapshot } from "../types/bridge"; +import { ALL_LOCALE_KEYS } from "../i18n/keys"; + +const t = (key: string): string => { + expect(ALL_LOCALE_KEYS).toContain(key); + return key; +}; + +const NOW = Date.parse("2026-09-30T12:00:00Z"); + +function hoursAgo(hours: number): string { + return new Date(NOW - hours * 3600 * 1000).toISOString(); +} + +function snapshot(overrides?: Partial): QuotaBurndownSnapshot { + return { + series: "session", + windowMinutes: 300, + start: hoursAgo(3), + reset: new Date(NOW + 2 * 3600 * 1000).toISOString(), + samples: [ + { capturedAt: hoursAgo(2), remainingPercent: 80 }, + { capturedAt: hoursAgo(1), remainingPercent: 55 }, + { capturedAt: new Date(NOW).toISOString(), remainingPercent: 40 }, + ], + ideal: [ + { capturedAt: hoursAgo(3), remainingPercent: 100 }, + { capturedAt: new Date(NOW + 2 * 3600 * 1000).toISOString(), remainingPercent: 0 }, + ], + ...overrides, + }; +} + +describe("QuotaBurndownChart", () => { + it("renders the observed line, ideal guide, remaining caption and capture age", () => { + render(); + + const svg = document.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute("data-series")).toBe("session"); + + const observed = document.querySelector(".quota-burndown-chart__observed"); + // Final point: 40% remaining -> y = 44.80 in the 76px viewBox. + expect(observed?.getAttribute("points")).toContain("44.80"); + + const ideal = document.querySelector(".quota-burndown-chart__ideal"); + expect(ideal).not.toBeNull(); + + // 40% remaining at the last capture. + expect(document.querySelector(".quota-burndown-chart__caption")?.textContent).toContain( + "40%", + ); + }); + + it("shows calendar dates on the axis for weekly-length windows and compact times for sessions", () => { + const start = "2026-09-23T12:00:00Z"; + const reset = "2026-09-30T12:00:00Z"; + render( + , + ); + const axis = document.querySelector(".quota-burndown-chart__axis"); + // A 7-day window: boundary labels include a month name. + expect(axis?.textContent).toMatch(/Sep/); + }); + + it("renders nothing without samples or a valid window span", () => { + const { container } = render( + , + ); + expect(container.querySelector("svg")).toBeNull(); + + const { container: container2 } = render( + , + ); + expect(container2.querySelector("svg")).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx b/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx new file mode 100644 index 00000000000..5c62a795910 --- /dev/null +++ b/apps/desktop-tauri/src/components/QuotaBurndownChart.tsx @@ -0,0 +1,123 @@ +import type { QuotaBurndownSnapshot, QuotaBurndownPoint } from "../types/bridge"; +import type { LocaleKey } from "../i18n/keys"; +import { formatRelativeUpdated } from "../lib/relativeTime"; + +const CHART_WIDTH = 300; +const CHART_HEIGHT = 76; +const CHART_PADDING = 4; + +function xFor(timeMs: number, startMs: number, endMs: number): number { + const span = Math.max(1, endMs - startMs); + const fraction = Math.min(1, Math.max(0, (timeMs - startMs) / span)); + return CHART_PADDING + fraction * (CHART_WIDTH - 2 * CHART_PADDING); +} + +function yFor(remainingPercent: number): number { + const clamped = Math.min(100, Math.max(0, remainingPercent)); + return ( + CHART_HEIGHT - + CHART_PADDING - + (clamped / 100) * (CHART_HEIGHT - 2 * CHART_PADDING) + ); +} + +function polyline(points: QuotaBurndownPoint[], startMs: number, endMs: number): string { + return points + .map((point) => { + const timeMs = Date.parse(point.capturedAt); + const x = xFor(timeMs, startMs, endMs); + const y = yFor(point.remainingPercent); + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); +} + +/** + * Recorded remaining-quota burndown (upstream 0.70.0 #4085): the solid line + * joins recorded remaining percentages (never extended past the last + * capture), the dashed line is the even-use guide from 100% at the window + * start to 0% at reset. Expired windows never reach this component (the + * backend model yields None for them). + */ +export default function QuotaBurndownChart({ + burndown, + t, + nowMs = Date.now(), +}: { + burndown: QuotaBurndownSnapshot; + t: (key: LocaleKey) => string; + nowMs?: number; +}) { + if (burndown.samples.length === 0) return null; + + const startMs = Date.parse(burndown.start); + const resetMs = Date.parse(burndown.reset); + if (!Number.isFinite(startMs) || !Number.isFinite(resetMs) || resetMs <= startMs) { + return null; + } + + const last = burndown.samples[burndown.samples.length - 1]; + const lastMs = Date.parse(last.capturedAt); + const idealStartX = xFor(Date.parse(burndown.ideal[0].capturedAt), startMs, resetMs); + const idealStartY = yFor(burndown.ideal[0].remainingPercent); + const idealEndX = xFor(Date.parse(burndown.ideal[1].capturedAt), startMs, resetMs); + const idealEndY = yFor(burndown.ideal[1].remainingPercent); + const remaining = Math.round(last.remainingPercent); + const captureAge = formatRelativeUpdated(lastMs, t, nowMs); + + return ( +
+ + + + + + +
+ {formatBoundary(burndown.start, resetMs - startMs)} + {formatBoundary(burndown.reset, resetMs - startMs)} +
+
+ {`${remaining}% ${t("PanelLeftSuffix")}`} + {`${t("ClaudeSwapHistoricalUsage")} · ${captureAge}`} +
+
+ ); +} + +/** Weekly/monthly-length windows show a calendar date; session ones stay compact. */ +function formatBoundary(iso: string, spanMs: number): string { + const date = new Date(iso); + if (!Number.isFinite(date.getTime())) return "—"; + const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + if (spanMs >= 24 * 60 * 60 * 1000) { + const day = date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); + return `${day} ${time}`; + } + return time; +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bff..245a01807a0 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -914,6 +914,10 @@ export const ALL_LOCALE_KEYS = [ "ProviderAutoResumeTitle", "ProviderAutoResumeAfterQuotaReset", "ProviderAutoResumeAfterQuotaResetHelper", + + // Recorded remaining-quota burndown (upstream 0.70.0 #4085) + "BurndownChartTitle", + "BurndownChartAriaLabel", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index a41405732c2..1b7bd83e232 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -5020,6 +5020,53 @@ html:has(.menu-surface--tray) { color: var(--text-primary); font-variant-numeric: tabular-nums; } + +/* ── Recorded quota burndown (upstream 0.70.0 #4085) ─────────────── */ +.quota-burndown-chart { + display: flex; + flex-direction: column; + gap: 5px; + padding-top: 2px; +} +.quota-burndown-chart svg { + width: 100%; + height: 76px; + overflow: visible; +} +.quota-burndown-chart__grid { + stroke: var(--usage-bar-track); + stroke-width: 1; +} +.quota-burndown-chart__ideal { + stroke: var(--text-secondary); + stroke-width: 1.5; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; +} +.quota-burndown-chart__observed { + fill: none; + stroke: var(--accent); + stroke-width: 2; + vector-effect: non-scaling-stroke; +} +.quota-burndown-chart__point { + fill: var(--accent); + vector-effect: non-scaling-stroke; +} +.quota-burndown-chart__axis { + display: flex; + justify-content: space-between; + color: var(--text-secondary); + font-size: 9px; +} +.quota-burndown-chart__caption { + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--text-secondary); + font-size: var(--font-caption); + font-variant-numeric: tabular-nums; +} .menu-metric__bar, .menu-card__pace-track { height: var(--bar-height); diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7de..9593b9dfd12 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -594,6 +594,22 @@ export interface PaceSnapshot { actualUsedPercent: number; } +/** One burndown chart point (RFC 3339 capture time + remaining percent). */ +export interface QuotaBurndownPoint { + capturedAt: string; + remainingPercent: number; +} + +/** Recorded remaining-quota burndown for one series (upstream 0.70.0 #4085). */ +export interface QuotaBurndownSnapshot { + series: "session" | "weekly"; + windowMinutes: number; + start: string; + reset: string; + samples: QuotaBurndownPoint[]; + ideal: [QuotaBurndownPoint, QuotaBurndownPoint]; +} + export interface SessionEquivalentForecastSnapshot { estimatedWindowsToExhaustWeekly: number; windowsUntilReset: number; @@ -686,6 +702,8 @@ export interface ProviderUsageSnapshot { fetchDurationMs?: number | null; wayfinderUsage?: WayfinderUsageSnapshot | null; sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; + /** Recorded remaining-quota burndown; Codex and Claude only. */ + quotaBurndown?: QuotaBurndownSnapshot | null; } export interface WayfinderRouteSummary { diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 436c7d5e0ac..4e53159512e 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -19,6 +19,7 @@ mod openai_dashboard; mod provider; mod provider_factory; mod provider_state; +mod quota_burndown; mod rate_window; mod redactor; mod session_equivalent_forecast; @@ -47,6 +48,10 @@ pub use openai_dashboard::*; pub use provider::*; pub use provider_factory::instantiate as instantiate_provider; pub use provider_state::*; +pub use quota_burndown::{ + MAX_SERIES_SAMPLES, PersistedPlanEntry, PersistedPlanSeries, QuotaBurndownModel, + QuotaBurndownSample, RESET_EQUIVALENCE_TOLERANCE_SECS, +}; pub use rate_window::*; pub use redactor::*; pub use session_equivalent_forecast::*; diff --git a/rust/src/core/quota_burndown.rs b/rust/src/core/quota_burndown.rs new file mode 100644 index 00000000000..ee92b5a659c --- /dev/null +++ b/rust/src/core/quota_burndown.rs @@ -0,0 +1,818 @@ +//! Recorded remaining-quota burndown for Codex and Claude (upstream 0.70.0 +//! #4085, `QuotaBurndownModel` / `PlanUtilizationHistoryStore`). +//! +//! Two halves: +//! +//! 1. [`plan_history_store`] persists the per-account plan-utilization history +//! that today lives only in the process-local `HISTORY_STORE` +//! ([`super::session_equivalent_forecast`]). One JSON document per +//! provider, partitioned by hashed account key — no identifiers beyond the +//! existing account-key rules, mirroring upstream's hashed account keys. +//! 2. [`QuotaBurndownModel`] turns a persisted series plus the live +//! `RateWindow` into the chart inputs: observed remaining samples (a usage +//! drop starts a new segment), the ideal 100%→0% guide, and the capture +//! age. Expired windows produce `None`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use super::rate_window::RateWindow; +use super::session_equivalent_forecast::PlanUtilizationHistoryEntry; +use crate::atomic_file; +use crate::secure_file; + +const STORE_VERSION: u32 = 1; +const STORE_RELATIVE_PATH: &str = "history/quota-burndown-v1.json"; + +/// Upstream's `resetEquivalenceTolerance`: resets within 2 minutes are the +/// same window. +pub const RESET_EQUIVALENCE_TOLERANCE_SECS: i64 = 120; + +/// Upstream caps each account series at 17,520 samples (~730 days hourly). +pub const MAX_SERIES_SAMPLES: usize = 17_520; + +#[derive(Debug, Error)] +pub enum QuotaBurndownStoreError { + #[error("quota burndown account key is empty")] + EmptyAccountKey, + #[error("quota burndown account key does not match the requested partition")] + AccountKeyMismatch, + #[error("failed to read quota burndown history: {0}")] + Read(#[source] std::io::Error), + #[error("failed to decode quota burndown history: {0}")] + Deserialize(#[source] serde_json::Error), + #[error("unsupported quota burndown store version {0}")] + UnsupportedVersion(u32), + #[error("failed to encode quota burndown history: {0}")] + Serialize(#[source] serde_json::Error), + #[error("failed to persist quota burndown history: {0}")] + Persist(#[source] std::io::Error), +} + +/// One persisted plan-utilization entry (serde form of the in-process +/// [`PlanUtilizationHistoryEntry`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PersistedPlanEntry { + /// RFC 3339 capture time. + pub captured_at: String, + pub used_percent: f64, + /// RFC 3339 reset time; absent when the provider did not advertise one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resets_at: Option, +} + +/// One named series: `session` (5h) or `weekly` (7d), per account. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PersistedPlanSeries { + pub name: String, + pub window_minutes: u32, + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProviderBurndownDocument { + version: u32, + /// Hashed account key -> series list. Hashing keeps persisted keys + /// anonymous for email/org identities, matching upstream's key rules. + accounts: BTreeMap>, +} + +impl Default for ProviderBurndownDocument { + fn default() -> Self { + Self { + version: STORE_VERSION, + accounts: BTreeMap::new(), + } + } +} + +/// Hash an in-process account key for persistence. Token-account ids are +/// already opaque; email/org keys are lowercased and hashed so the file +/// carries no plain addresses and case variants collapse (the `ponytail` +/// note in `session_equivalent_forecast`). +pub fn persisted_account_key(account_key: Option<&str>) -> String { + match account_key.map(str::trim).filter(|k| !k.is_empty()) { + Some(key) if key.starts_with("token-account:") => key.to_ascii_lowercase(), + Some(key) => { + format!( + "sha256:{}", + super::aws_signing::sha256_hex(key.to_ascii_lowercase().as_bytes()) + ) + } + None => String::new(), + } +} + +pub fn store_path(config_root: &Path) -> PathBuf { + config_root.join(STORE_RELATIVE_PATH) +} + +fn validate_key(account_key: &str) -> Result<(), QuotaBurndownStoreError> { + if account_key.trim().is_empty() { + Err(QuotaBurndownStoreError::EmptyAccountKey) + } else { + Ok(()) + } +} + +fn validate_document(document: &ProviderBurndownDocument) -> Result<(), QuotaBurndownStoreError> { + if document.version != STORE_VERSION { + return Err(QuotaBurndownStoreError::UnsupportedVersion( + document.version, + )); + } + Ok(()) +} + +fn load_document(path: &Path) -> Result { + let raw = match secure_file::read_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ProviderBurndownDocument::default()); + } + Err(error) => return Err(QuotaBurndownStoreError::Read(error)), + }; + let document: ProviderBurndownDocument = + serde_json::from_str(&raw).map_err(QuotaBurndownStoreError::Deserialize)?; + validate_document(&document)?; + Ok(document) +} + +/// The persisted series list for one hashed account key, or empty. +pub fn load_series( + config_root: &Path, + provider_id: &str, + account_key: &str, +) -> Result, QuotaBurndownStoreError> { + validate_key(account_key)?; + let path = store_path(config_root).with_file_name(provider_file_name(provider_id)); + let document = load_document(&path)?; + Ok(document + .accounts + .get(account_key) + .cloned() + .unwrap_or_default()) +} + +/// Merge `incoming` entries into the persisted series for one account and +/// write the document back atomically. Entries are sorted by capture time and +/// deduplicated; the per-series sample cap is enforced. +pub fn merge_and_persist_series( + config_root: &Path, + provider_id: &str, + account_key: &str, + incoming: Vec, +) -> Result, QuotaBurndownStoreError> { + validate_key(account_key)?; + let path = store_path(config_root).with_file_name(provider_file_name(provider_id)); + let mut document = load_document(&path)?; + let series = document + .accounts + .entry(account_key.to_string()) + .or_default(); + merge_series(series, incoming); + if series.is_empty() { + document.accounts.remove(account_key); + } + persist_document(&path, &document)?; + Ok(document + .accounts + .get(account_key) + .cloned() + .unwrap_or_default()) +} + +fn provider_file_name(provider_id: &str) -> String { + format!("{provider_id}-burndown-v1.json") +} + +/// Persist one recorded observation pair (session + optional weekly) for the +/// account. Best-effort: `Err` never blocks the in-process forecast path. +pub fn persist_recorded_windows( + provider_id: &str, + account_key: Option<&str>, + session: Option<&PlanUtilizationHistoryEntry>, + weekly: Option<&PlanUtilizationHistoryEntry>, + _now: DateTime, +) -> Result<(), QuotaBurndownStoreError> { + let key = persisted_account_key(account_key); + if key.is_empty() { + // Upstream keeps an unscoped bucket; locally history without an + // account key cannot be told apart on a shared machine, so it stays + // process-local (no identifier is invented). + return Ok(()); + } + let Some(config_root) = dirs::config_dir().map(|root| root.join("CodexBar")) else { + return Ok(()); + }; + let mut series = Vec::new(); + if let Some(entry) = session { + series.push(PersistedPlanSeries { + name: "session".to_string(), + window_minutes: super::session_equivalent_forecast::SESSION_WINDOW_MINUTES, + entries: vec![PersistedPlanEntry::from_history(entry)], + }); + } + if let Some(entry) = weekly { + series.push(PersistedPlanSeries { + name: "weekly".to_string(), + window_minutes: super::session_equivalent_forecast::WEEKLY_WINDOW_MINUTES, + entries: vec![PersistedPlanEntry::from_history(entry)], + }); + } + if series.is_empty() { + return Ok(()); + } + merge_and_persist_series(&config_root, provider_id, &key, series).map(|_| ()) +} + +/// Load the persisted series for one provider+account (empty when the key is +/// missing or the store is unreadable). +pub fn load_persisted_series( + provider_id: &str, + account_key: Option<&str>, +) -> Vec { + let key = persisted_account_key(account_key); + if key.is_empty() { + return Vec::new(); + } + dirs::config_dir() + .map(|root| root.join("CodexBar")) + .and_then(|config_root| { + load_series(&config_root, provider_id, &key) + .map_err(|error| tracing::debug!(%error, "quota burndown load failed")) + .ok() + }) + .unwrap_or_default() +} + +fn merge_series(existing: &mut Vec, incoming: Vec) { + for series in incoming { + if series.entries.is_empty() { + continue; + } + if let Some(target) = existing.iter_mut().find(|target| { + target.name == series.name && target.window_minutes == series.window_minutes + }) { + target.entries.extend(series.entries); + normalize_entries(&mut target.entries); + } else { + let mut series = series; + normalize_entries(&mut series.entries); + existing.push(series); + } + } + existing.retain(|series| !series.entries.is_empty()); + existing.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.window_minutes.cmp(&right.window_minutes)) + }); +} + +fn normalize_entries(entries: &mut Vec) { + entries.sort_by(|left, right| { + left.captured_at + .cmp(&right.captured_at) + .then_with(|| left.used_percent.total_cmp(&right.used_percent)) + .then_with(|| left.resets_at.cmp(&right.resets_at)) + }); + entries.dedup(); + if entries.len() > MAX_SERIES_SAMPLES { + let drop = entries.len() - MAX_SERIES_SAMPLES; + entries.drain(0..drop); + } +} + +fn persist_document( + path: &Path, + document: &ProviderBurndownDocument, +) -> Result<(), QuotaBurndownStoreError> { + if document.accounts.is_empty() && !path.exists() { + return Ok(()); + } + let raw = serde_json::to_string_pretty(document).map_err(QuotaBurndownStoreError::Serialize)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(QuotaBurndownStoreError::Read)?; + } + if document.accounts.is_empty() { + // Last account for the provider was removed: drop the file. + match std::fs::remove_file(path) { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(QuotaBurndownStoreError::Persist(error)), + } + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("quota-burndown-v1.json"); + let temp = path.with_file_name(format!(".{file_name}.tmp-{}", Uuid::new_v4())); + let result = (|| { + secure_file::write_string(&temp, &raw)?; + atomic_file::replace_staged(&temp, path) + })(); + if result.is_err() { + let _cleanup = std::fs::remove_file(&temp); + } + result.map_err(QuotaBurndownStoreError::Persist)?; + Ok(()) +} + +impl PersistedPlanEntry { + pub fn from_history(entry: &PlanUtilizationHistoryEntry) -> Self { + Self { + captured_at: entry.captured_at.to_rfc3339(), + used_percent: entry.used_percent, + resets_at: entry.resets_at.map(|dt| dt.to_rfc3339()), + } + } + + pub fn to_history(&self) -> Option { + Some(PlanUtilizationHistoryEntry { + captured_at: DateTime::parse_from_rfc3339(&self.captured_at) + .ok()? + .with_timezone(&Utc), + used_percent: self.used_percent, + resets_at: self + .resets_at + .as_ref() + .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) + .map(|dt| dt.with_timezone(&Utc)), + }) + } +} + +/// One chart sample: capture time and remaining percent (0-100). +#[derive(Debug, Clone, PartialEq)] +pub struct QuotaBurndownSample { + pub captured_at: DateTime, + pub remaining_percent: f64, +} + +/// Chart inputs for one recorded quota window (upstream `QuotaBurndownModel`). +/// +/// `now` is the live reading's capture time; upstream passes the latest +/// history capture so the line never extends past the last capture. +#[derive(Debug, Clone, PartialEq)] +pub struct QuotaBurndownModel { + pub start: DateTime, + pub reset: DateTime, + pub samples: Vec, + pub ideal: [QuotaBurndownSample; 2], +} + +impl QuotaBurndownModel { + /// Build from a history series plus the live window. Returns `None` for + /// informational placeholders, missing duration/reset, or an expired / + /// not-yet-started window (`start <= now < reset` must hold). + pub fn build( + entries: &[PlanUtilizationHistoryEntry], + window: &RateWindow, + now: DateTime, + ) -> Option { + if window.is_informational || !window.used_percent.is_finite() { + return None; + } + let window_minutes = window.window_minutes?; + if window_minutes == 0 { + return None; + } + let reset = window.resets_at?; + let duration = chrono::Duration::minutes(i64::from(window_minutes)); + if duration <= chrono::Duration::zero() { + return None; + } + let start = reset - duration; + if start > now || now >= reset { + return None; + } + + let tolerance = chrono::Duration::seconds(RESET_EQUIVALENCE_TOLERANCE_SECS); + let mut historical: Vec<(DateTime, f64)> = entries + .iter() + .filter(|entry| { + entry.captured_at >= start + && entry.captured_at <= now + && entry.used_percent.is_finite() + && entry + .resets_at + .map(|entry_reset| (entry_reset - reset).abs() <= tolerance) + .unwrap_or(true) + }) + .map(|entry| (entry.captured_at, entry.used_percent)) + .collect(); + + // Same-timestamp entries collapse to the latest value (the live + // reading wins a tie), matching upstream's overwrite rule. + historical.sort_by_key(|(captured_at, _)| *captured_at); + let mut deduped: Vec<(DateTime, f64)> = Vec::with_capacity(historical.len()); + for (date, used) in historical { + if let Some(last) = deduped.last_mut() + && last.0 == date + { + last.1 = used; + continue; + } + deduped.push((date, used)); + } + + // Walk history + the live reading: a decrease in used percent starts + // a new segment (upstream's reset-boundary segmentation). + let mut current: Vec<(DateTime, f64)> = Vec::new(); + for (date, used) in deduped + .into_iter() + .chain(std::iter::once((now, window.used_percent))) + { + if let Some(last) = current.last_mut() { + if last.0 == date { + last.1 = used; + continue; + } + if used < last.1 { + current.clear(); + } + } + current.push((date, used)); + } + + Some(Self { + start, + reset, + samples: current + .into_iter() + .map(|(captured_at, used)| QuotaBurndownSample { + captured_at, + remaining_percent: (100.0 - used).clamp(0.0, 100.0), + }) + .collect(), + ideal: [ + QuotaBurndownSample { + captured_at: start, + remaining_percent: 100.0, + }, + QuotaBurndownSample { + captured_at: reset, + remaining_percent: 0.0, + }, + ], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn at(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .unwrap() + .with_timezone(&Utc) + } + + fn entry(captured: &str, used: f64, reset: Option<&str>) -> PlanUtilizationHistoryEntry { + PlanUtilizationHistoryEntry { + captured_at: at(captured), + used_percent: used, + resets_at: reset.map(at), + } + } + + fn window(used: f64, minutes: u32, reset: DateTime) -> RateWindow { + let mut window = RateWindow::new(used); + window.window_minutes = Some(minutes); + window.resets_at = Some(reset); + window + } + + fn persisted(name: &str, entries: Vec) -> PersistedPlanSeries { + PersistedPlanSeries { + name: name.to_string(), + window_minutes: if name == "session" { 300 } else { 10_080 }, + entries, + } + } + + fn persisted_entry(captured: &str, used: f64, reset: Option<&str>) -> PersistedPlanEntry { + PersistedPlanEntry { + captured_at: captured.to_string(), + used_percent: used, + resets_at: reset.map(str::to_string), + } + } + + #[test] + fn builds_current_window_samples_and_ideal_line() { + let now = at("2026-09-30T12:00:00Z"); + let reset = now + chrono::Duration::hours(2); + let entries = vec![ + entry("2026-09-30T10:00:00Z", 20.0, Some("2026-09-30T14:00:00Z")), + entry("2026-09-30T11:00:00Z", 45.0, Some("2026-09-30T14:00:00Z")), + ]; + let model = QuotaBurndownModel::build(&entries, &window(60.0, 300, reset), now).unwrap(); + assert_eq!(model.start, reset - chrono::Duration::hours(5)); + assert_eq!(model.reset, reset); + assert_eq!( + model.samples, + vec![ + QuotaBurndownSample { + captured_at: at("2026-09-30T10:00:00Z"), + remaining_percent: 80.0, + }, + QuotaBurndownSample { + captured_at: at("2026-09-30T11:00:00Z"), + remaining_percent: 55.0, + }, + QuotaBurndownSample { + captured_at: now, + remaining_percent: 40.0, + }, + ] + ); + assert_eq!( + model.ideal, + [ + QuotaBurndownSample { + captured_at: reset - chrono::Duration::hours(5), + remaining_percent: 100.0, + }, + QuotaBurndownSample { + captured_at: reset, + remaining_percent: 0.0, + }, + ] + ); + } + + #[test] + fn isolates_current_reset_and_keeps_only_newest_segment_after_usage_drop() { + let now = at("2026-09-30T12:00:00Z"); + let reset = now + chrono::Duration::hours(2); + let entries = vec![ + entry("2026-09-30T06:00:00Z", 90.0, Some("2026-09-30T09:00:00Z")), + entry("2026-09-30T08:00:00Z", 70.0, Some("2026-09-30T14:00:00Z")), + entry("2026-09-30T10:00:00Z", 80.0, Some("2026-09-30T14:00:00Z")), + entry("2026-09-30T10:30:00Z", 85.0, Some("2026-09-30T09:00:00Z")), + entry("2026-09-30T11:00:00Z", 10.0, Some("2026-09-30T14:00:00Z")), + entry("2026-09-30T11:30:00Z", 25.0, Some("2026-09-30T14:00:00Z")), + ]; + let model = QuotaBurndownModel::build(&entries, &window(30.0, 300, reset), now).unwrap(); + assert_eq!( + model.samples, + vec![ + QuotaBurndownSample { + captured_at: at("2026-09-30T11:00:00Z"), + remaining_percent: 90.0, + }, + QuotaBurndownSample { + captured_at: at("2026-09-30T11:30:00Z"), + remaining_percent: 75.0, + }, + QuotaBurndownSample { + captured_at: now, + remaining_percent: 70.0, + }, + ] + ); + } + + #[test] + fn accepts_small_reset_drift_while_excluding_another_cycle() { + let now = at("2026-09-30T12:00:00Z"); + let reset = now + chrono::Duration::hours(2); + let entries = vec![ + entry("2026-09-30T10:00:00Z", 20.0, Some("2026-09-30T14:01:30Z")), + entry("2026-09-30T11:00:00Z", 40.0, Some("2026-09-30T09:00:00Z")), + ]; + let model = QuotaBurndownModel::build(&entries, &window(50.0, 300, reset), now).unwrap(); + assert_eq!( + model.samples, + vec![ + QuotaBurndownSample { + captured_at: at("2026-09-30T10:00:00Z"), + remaining_percent: 80.0, + }, + QuotaBurndownSample { + captured_at: now, + remaining_percent: 50.0, + }, + ] + ); + } + + #[test] + fn requires_valid_current_reset_and_duration() { + let now = at("2026-09-30T12:00:00Z"); + let entries: Vec = Vec::new(); + // No reset. + let mut no_reset = window(10.0, 300, now + chrono::Duration::hours(1)); + no_reset.resets_at = None; + assert!(QuotaBurndownModel::build(&entries, &no_reset, now).is_none()); + // Zero duration. + assert!( + QuotaBurndownModel::build( + &entries, + &window(10.0, 0, now + chrono::Duration::hours(1)), + now + ) + .is_none() + ); + // Informational placeholder. + let placeholder = RateWindow::informational("no session"); + assert!(QuotaBurndownModel::build(&entries, &placeholder, now).is_none()); + // Expired window (now past reset). + assert!( + QuotaBurndownModel::build( + &entries, + &window(10.0, 300, now - chrono::Duration::hours(1)), + now + ) + .is_none() + ); + } + + #[test] + fn uses_live_window_when_history_has_no_current_samples() { + let now = at("2026-09-30T12:00:00Z"); + let reset = now + chrono::Duration::hours(2); + let entries = vec![entry( + "2026-09-30T06:00:00Z", + 80.0, + Some("2026-09-30T09:00:00Z"), + )]; + let model = QuotaBurndownModel::build(&entries, &window(125.0, 300, reset), now).unwrap(); + assert_eq!( + model.samples, + vec![QuotaBurndownSample { + captured_at: now, + remaining_percent: 0.0, + }] + ); + } + + #[test] + fn deduplicates_timestamps_and_skips_nonfinite_history() { + let now = at("2026-09-30T12:00:00Z"); + let reset = now + chrono::Duration::hours(2); + let duplicate = "2026-09-30T11:00:00Z"; + let entries = vec![ + PlanUtilizationHistoryEntry { + captured_at: at("2026-09-30T10:00:00Z"), + used_percent: f64::NAN, + resets_at: Some(reset), + }, + entry(duplicate, 20.0, Some("2026-09-30T14:00:00Z")), + entry(duplicate, 30.0, Some("2026-09-30T14:00:00Z")), + entry("2026-09-30T12:00:00Z", 40.0, Some("2026-09-30T14:00:00Z")), + ]; + let model = QuotaBurndownModel::build(&entries, &window(150.0, 300, reset), now).unwrap(); + assert_eq!( + model.samples, + vec![ + QuotaBurndownSample { + captured_at: at(duplicate), + remaining_percent: 70.0, + }, + QuotaBurndownSample { + captured_at: now, + remaining_percent: 0.0, + }, + ] + ); + // Nonfinite live usage is unreachable through the public API: + // `RateWindow::new` normalizes it to 0.0 (see `finite_percent`). + } + + #[test] + fn persisted_keys_round_trip_and_hash_personal_identities() { + assert_eq!( + persisted_account_key(Some("token-account:0f0e0d0c-0000-0000-0000-000000000000")), + "token-account:0f0e0d0c-0000-0000-0000-000000000000" + ); + let hashed = persisted_account_key(Some("Person@Example.com")); + assert!(hashed.starts_with("sha256:")); + assert!(!hashed.contains("Person")); + assert_eq!(hashed, persisted_account_key(Some("person@example.com"))); + assert_eq!(persisted_account_key(None), ""); + } + + #[test] + fn persist_reload_and_account_isolation() { + let root = tempdir().unwrap(); + let a = persisted( + "session", + vec![persisted_entry( + "2026-09-30T10:00:00Z", + 20.0, + Some("2026-09-30T14:00:00Z"), + )], + ); + let b = persisted( + "session", + vec![persisted_entry( + "2026-09-30T11:00:00Z", + 40.0, + Some("2026-09-30T14:00:00Z"), + )], + ); + let key_a = persisted_account_key(Some("a@example.com")); + let key_b = persisted_account_key(Some("b@example.com")); + merge_and_persist_series(root.path(), "codex", &key_a, vec![a.clone()]).unwrap(); + merge_and_persist_series(root.path(), "codex", &key_b, vec![b.clone()]).unwrap(); + assert_eq!(load_series(root.path(), "codex", &key_a).unwrap(), vec![a]); + assert_eq!(load_series(root.path(), "codex", &key_b).unwrap(), vec![b]); + } + + #[test] + fn duplicate_merge_is_idempotent_and_sorted() { + let root = tempdir().unwrap(); + let key = persisted_account_key(Some("a@example.com")); + let early = persisted_entry("2026-09-30T10:00:00Z", 20.0, None); + let late = persisted_entry("2026-09-30T11:00:00Z", 40.0, None); + merge_and_persist_series( + root.path(), + "codex", + &key, + vec![persisted("session", vec![late.clone(), early.clone()])], + ) + .unwrap(); + let first = load_series(root.path(), "codex", &key).unwrap(); + merge_and_persist_series( + root.path(), + "codex", + &key, + vec![persisted("session", vec![late])], + ) + .unwrap(); + let second = load_series(root.path(), "codex", &key).unwrap(); + assert_eq!(first, second); + assert_eq!(second[0].entries[0], early); + } + + #[test] + fn series_merge_extends_and_caps_samples() { + let mut existing = vec![persisted( + "session", + vec![persisted_entry("2026-09-30T10:00:00Z", 20.0, None)], + )]; + let mut flood = persisted("session", Vec::new()); + flood.entries = (0..MAX_SERIES_SAMPLES + 10) + .map(|index| { + persisted_entry( + &format!("2026-09-{:02}T10:00:00Z", 1 + index / 24), + (index % 100) as f64, + None, + ) + }) + .collect(); + merge_series(&mut existing, vec![flood]); + assert_eq!(existing.len(), 1); + assert_eq!(existing[0].entries.len(), MAX_SERIES_SAMPLES); + // 17531 entries sort into the cap; the first 11 drop (Sept 1's used + // 0..10), so the oldest kept entry is used 11 on Sept 1. + assert_eq!( + existing[0].entries[0], + persisted_entry("2026-09-01T10:00:00Z", 11.0, None) + ); + } + + #[test] + fn empty_incoming_merge_is_a_noop_without_creating_the_file() { + let root = tempdir().unwrap(); + let key = persisted_account_key(Some("a@example.com")); + let path = store_path(root.path()).with_file_name(provider_file_name("codex")); + merge_and_persist_series(root.path(), "codex", &key, Vec::new()).unwrap(); + assert!(!path.exists()); + // Persisting real entries creates it; another empty merge keeps it. + merge_and_persist_series( + root.path(), + "codex", + &key, + vec![persisted( + "session", + vec![persisted_entry("2026-09-30T10:00:00Z", 20.0, None)], + )], + ) + .unwrap(); + assert!(path.exists()); + merge_and_persist_series(root.path(), "codex", &key, Vec::new()).unwrap(); + assert!(path.exists()); + } + + #[test] + fn history_entry_serde_round_trip() { + let history = entry("2026-09-30T10:00:00Z", 42.5, Some("2026-09-30T14:00:00Z")); + let persisted = PersistedPlanEntry::from_history(&history); + let parsed = persisted.to_history().unwrap(); + assert_eq!(parsed, history); + let no_reset = entry("2026-09-30T10:00:00Z", 10.0, None); + let persisted = PersistedPlanEntry::from_history(&no_reset); + assert_eq!(persisted.to_history().unwrap(), no_reset); + } +} diff --git a/rust/src/core/session_equivalent_forecast.rs b/rust/src/core/session_equivalent_forecast.rs index 6c09eb3bdec..6df3233a69f 100644 --- a/rust/src/core/session_equivalent_forecast.rs +++ b/rust/src/core/session_equivalent_forecast.rs @@ -5,9 +5,11 @@ use chrono::{DateTime, Datelike, Duration, Utc, Weekday}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{LazyLock, Mutex}; +use super::quota_burndown::{self, PersistedPlanEntry as PersistedEntry}; + /// Canonical session window length (5 hours). pub const SESSION_WINDOW_MINUTES: u32 = 300; /// Canonical weekly window length (7 days). @@ -600,6 +602,37 @@ impl SessionEquivalentHistoryStore { } out } + + /// Merge persisted series into the in-process store for `scope`. Older + /// in-process observations win ties; the result stays sorted so the + /// estimator's chronological check keeps passing. + pub fn adopt_persisted( + &mut self, + scope: &ForecastScope, + persisted: &[quota_burndown::PersistedPlanSeries], + ) { + for series in persisted { + let entries: Vec = series + .entries + .iter() + .filter_map(PersistedEntry::to_history) + .collect(); + if entries.is_empty() { + continue; + } + let hist = self.by_scope.entry(scope.clone()).or_default(); + let ring = quota_burndown::MAX_SERIES_SAMPLES; + match series.name.as_str() { + "session" => { + merge_ring(&mut hist.session, entries, ring); + } + "weekly" => { + merge_ring(&mut hist.weekly, entries, ring); + } + _ => {} + } + } + } } fn push_ring( @@ -609,9 +642,61 @@ fn push_ring( ) { buf.push(entry); if buf.len() > ring { - let drop_n = buf.len() - ring; - buf.drain(0..drop_n); + let drop = buf.len() - ring; + buf.drain(0..drop); + } +} + +/// Merge loaded entries into a ring buffer: append, sort, dedup, cap. +fn merge_ring( + buf: &mut Vec, + entries: Vec, + ring: usize, +) { + buf.extend(entries); + buf.sort_by(|left, right| { + left.captured_at + .cmp(&right.captured_at) + .then_with(|| left.used_percent.total_cmp(&right.used_percent)) + }); + buf.dedup(); + if buf.len() > ring { + let drop = buf.len() - ring; + buf.drain(0..drop); + } +} + +/// Load the persisted burndown history for one provider+account into the +/// process-local store, so history survives a restart. Runs once per scope; +/// later calls are cheap no-ops (the loaded marker is kept in the store). +pub fn load_persisted_history( + provider_id: &str, + account_key: Option<&str>, +) -> Vec { + static LOADED: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + let scope = ForecastScope::new(provider_id, account_key); + if let Ok(guard) = LOADED.lock() + && guard.contains(&scope) + { + return global_history_store() + .lock() + .ok() + .map(|store| store.histories(&scope)) + .unwrap_or_default(); + } + let series = quota_burndown::load_persisted_series(provider_id, account_key); + if let Ok(mut loaded) = LOADED.lock() { + loaded.insert(scope.clone()); + } + if let Ok(mut store) = global_history_store().lock() { + store.adopt_persisted(&scope, &series); } + global_history_store() + .lock() + .ok() + .map(|store| store.histories(&scope)) + .unwrap_or_default() } /// Global in-process history used by Claude/Codex snapshot refresh. @@ -658,11 +743,69 @@ pub fn record_provider_windows( if let Ok(mut guard) = global_history_store().lock() { guard.record( &ForecastScope::new(provider_id, account_key), - Some(session_entry), - weekly_entry, + Some(session_entry.clone()), + weekly_entry.clone(), SessionEquivalentBurnEstimator::DEFAULT_SAMPLE_LIMIT, ); } + + // Best-effort persistence for the burndown chart (upstream #4085 keeps a + // durable store; a failed write only costs the chart, never the forecast). + if let Err(error) = quota_burndown::persist_recorded_windows( + provider_id, + account_key, + Some(&session_entry), + weekly_entry.as_ref(), + now, + ) { + tracing::debug!(%error, "quota burndown persistence failed"); + } +} + +/// Best-effort persistence of one recorded observation pair for the burndown +/// chart. Failures are logged at debug level and never surface. +pub fn persist_recorded_windows( + provider_id: &str, + account_key: Option<&str>, + session: Option<&PlanUtilizationHistoryEntry>, + weekly: Option<&PlanUtilizationHistoryEntry>, + _now: DateTime, +) -> Result<(), quota_burndown::QuotaBurndownStoreError> { + let config_root = match crate::logging::config_root() { + Some(root) => root, + None => return Ok(()), + }; + let mut session_series = quota_burndown::PersistedPlanSeries { + name: "session".to_string(), + window_minutes: SESSION_WINDOW_MINUTES, + entries: Vec::new(), + }; + if let Some(entry) = session { + session_series + .entries + .push(quota_burndown::PersistedPlanEntry::from_history(entry)); + } + let mut weekly_series = quota_burndown::PersistedPlanSeries { + name: "weekly".to_string(), + window_minutes: WEEKLY_WINDOW_MINUTES, + entries: Vec::new(), + }; + if let Some(entry) = weekly { + weekly_series + .entries + .push(quota_burndown::PersistedPlanEntry::from_history(entry)); + } + let key = quota_burndown::persisted_account_key(account_key); + if key.is_empty() { + return Ok(()); + } + quota_burndown::merge_and_persist_series( + &config_root, + provider_id, + &key, + vec![session_series, weekly_series], + ) + .map(|_| ()) } /// Last learned full-session burn estimate retained across idle refreshes. diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac42..aa470c64756 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1194,6 +1194,10 @@ locale_keys! { ProviderAutoResumeTitle, ProviderAutoResumeAfterQuotaReset, ProviderAutoResumeAfterQuotaResetHelper, + + // Recorded remaining-quota burndown (upstream 0.70.0 #4085) + BurndownChartTitle, + BurndownChartAriaLabel, } #[cfg(test)] diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8b..30fed805109 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -881,3 +881,6 @@ ClaudeSwapHistoricalUsage = Last known usage ClaudeSwapHistoricalCapturedAt = captured ClaudeSwapSpend = Spend ClaudeSwapDisabled = Disabled in claude-swap + +BurndownChartTitle = Usage remaining +BurndownChartAriaLabel = Recorded remaining quota burndown From 15110215657604f4e5923523466329c262ab5e31 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:49:51 +0700 Subject: [PATCH 2/2] Gate the burndown chart on provider.quotaBurndown presence --- apps/desktop-tauri/src/components/MenuCardDetails.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index bc2650d72fd..25e6ba48075 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -438,6 +438,7 @@ export interface MenuCardPresence { hasCreditsHistory: boolean; hasUsageBreakdown: boolean; hasQuotaWindowHistory: boolean; + hasBurndown: boolean; localUsage: ProviderChartData["localUsage"] | null; wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null; hasDetails: boolean; @@ -473,8 +474,13 @@ export function describeCard( chartData !== null && chartData.usageBreakdown.length > 0; const hasQuotaWindowHistory = chartData !== null && (chartData.quotaWindowHistory?.windows.length ?? 0) > 0; + const hasBurndown = provider.quotaBurndown != null; const hasCharts = - hasCostHistory || hasCreditsHistory || hasUsageBreakdown || hasQuotaWindowHistory; + hasCostHistory || + hasCreditsHistory || + hasUsageBreakdown || + hasQuotaWindowHistory || + hasBurndown; const isWayfinder = provider.providerId === "wayfinder"; const localUsage = provider.error ? null : chartData?.localUsage ?? null; const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; @@ -513,6 +519,7 @@ export function describeCard( hasCreditsHistory, hasUsageBreakdown, hasQuotaWindowHistory, + hasBurndown, localUsage, wayfinderUsage, hasDetails, @@ -554,6 +561,7 @@ export default function MenuCardDetails({ hasCreditsHistory, hasUsageBreakdown, hasQuotaWindowHistory, + hasBurndown, localUsage, wayfinderUsage, } = presence;