Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<QuotaBurndownPointSnapshot>,
pub ideal: [QuotaBurndownPointSnapshot; 2],
}

/// Session-equivalent weekly forecast for Claude/Codex menu secondary line.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -269,6 +292,10 @@ pub struct ProviderUsageSnapshot {
pub wayfinder_usage: Option<codexbar::core::WayfinderUsageSnapshot>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub session_equivalent_forecast: Option<SessionEquivalentForecastSnapshot>,
/// 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<QuotaBurndownSnapshot>,
}

fn default_display_name() -> String {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -493,6 +526,7 @@ impl ProviderUsageSnapshot {
fetch_duration_ms: None,
wayfinder_usage: result.wayfinder_usage.clone(),
session_equivalent_forecast,
quota_burndown,
}
}

Expand Down Expand Up @@ -543,6 +577,7 @@ impl ProviderUsageSnapshot {
fetch_duration_ms: None,
wayfinder_usage: None,
session_equivalent_forecast: None,
quota_burndown: None,
}
}
}
Expand Down Expand Up @@ -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<QuotaBurndownSnapshot> {
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<QuotaBurndownSnapshot> {
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<codexbar::core::PlanUtilizationHistoryEntry> {
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 {
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop-tauri/src/components/MenuCardDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -437,6 +438,7 @@ export interface MenuCardPresence {
hasCreditsHistory: boolean;
hasUsageBreakdown: boolean;
hasQuotaWindowHistory: boolean;
hasBurndown: boolean;
localUsage: ProviderChartData["localUsage"] | null;
wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null;
hasDetails: boolean;
Expand Down Expand Up @@ -472,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;
Expand Down Expand Up @@ -512,6 +519,7 @@ export function describeCard(
hasCreditsHistory,
hasUsageBreakdown,
hasQuotaWindowHistory,
hasBurndown,
localUsage,
wayfinderUsage,
hasDetails,
Expand Down Expand Up @@ -553,6 +561,7 @@ export default function MenuCardDetails({
hasCreditsHistory,
hasUsageBreakdown,
hasQuotaWindowHistory,
hasBurndown,
localUsage,
wayfinderUsage,
} = presence;
Expand Down Expand Up @@ -778,6 +787,9 @@ export default function MenuCardDetails({
{hasQuotaWindowHistory && (
<QuotaWindowHistory history={chartData!.quotaWindowHistory} t={t} />
)}
{provider.quotaBurndown && (
<QuotaBurndownChart burndown={provider.quotaBurndown} t={t} />
)}
</section>
)}
</div>
Expand Down
98 changes: 98 additions & 0 deletions apps/desktop-tauri/src/components/QuotaBurndownChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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>): 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(<QuotaBurndownChart burndown={snapshot()} t={t} nowMs={NOW} />);

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(
<QuotaBurndownChart
burndown={snapshot({
series: "weekly",
windowMinutes: 10_080,
start,
reset,
samples: [{ capturedAt: "2026-09-29T12:00:00Z", remainingPercent: 60 }],
ideal: [
{ capturedAt: start, remainingPercent: 100 },
{ capturedAt: reset, remainingPercent: 0 },
],
})}
t={t}
nowMs={NOW}
/>,
);
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(
<QuotaBurndownChart burndown={snapshot({ samples: [] })} t={t} nowMs={NOW} />,
);
expect(container.querySelector("svg")).toBeNull();

const { container: container2 } = render(
<QuotaBurndownChart
burndown={snapshot({ start: "2026-09-30T13:00:00Z", reset: "2026-09-30T12:00:00Z" })}
t={t}
nowMs={NOW}
/>,
);
expect(container2.querySelector("svg")).toBeNull();
});
});
Loading