From 0549b2bf67316920a4dfba7b23645c0660af7f27 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 15:52:02 +0700 Subject: [PATCH 1/5] Show a daily target for monthly allowances --- CHANGELOG.md | 1 + .../src-tauri/src/commands/bridge/pace.rs | 111 ++++++++++- apps/desktop-tauri/src/i18n/keyGroups/core.ts | 3 + .../src/lib/metricRowModel.test.ts | 47 +++++ apps/desktop-tauri/src/lib/metricRowModel.ts | 32 +++- apps/desktop-tauri/src/types/bridge/usage.ts | 11 ++ rust/src/core/pace_capability.rs | 177 +++++++++++++++++- rust/src/core/rate_window.rs | 43 +++++ rust/src/core/usage_pace.rs | 137 +++++++++++++- rust/src/locale.rs | 3 + rust/src/locale/en-US.ftl | 3 + rust/src/locale/es-MX.ftl | 3 + rust/src/locale/ja-JP.ftl | 3 + rust/src/locale/ko-KR.ftl | 3 + rust/src/locale/pt-BR.ftl | 3 + rust/src/locale/ru-RU.ftl | 3 + rust/src/locale/tr-TR.ftl | 3 + rust/src/locale/uk-UA.ftl | 3 + rust/src/locale/zh-CN.ftl | 3 + rust/src/locale/zh-TW.ftl | 3 + rust/src/providers/copilot/api.rs | 2 + rust/src/providers/futureagi/mod.rs | 4 +- rust/src/providers/futureagi/tests.rs | 5 + rust/src/providers/notion/mod.rs | 11 ++ 24 files changed, 610 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac06238ab6..79206c33e38 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- Pace: a monthly allowance shows a per-day target, the remaining allowance divided by the local days left including today, in its own unit (credits, requests or %). It appears on the pace line when pace is on, and shows nothing when usage or the reset is unknown or the reset has passed. Notion's credit allowances and FutureAGI's credit and request allowances report their limits (upstream #4692). - Overview: disabling a provider, in Settings or from the tray menu, removes its card and account rows at once instead of after a relaunch. claude-swap and tcr Overview rows now fill from a background read during refresh (at most every 5 minutes), so you no longer need to open Settings first. - **Claude**: built-in price rows choose one long-context tier per request from input plus all cache tokens and bill the whole request at that tier, as upstream does. Opus 4.6 and Sonnet 4.6 use long-context rates only for usage dated before the upstream cutoff. - Tray and Settings: percentages always say "left" or "used" and follow **Show usage as used** (the tray tooltip, tray status rows, the Claude accounts menu and the claude-swap/tcr list). "1 minute ago" is singular in every language. Under **Hide personal information**, Claude and Grok accounts are numbered in list order, and the claude-swap list masks emails as soon as the setting changes. diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index 02db1d6af79..112ae6c23ae 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -36,6 +36,28 @@ pub struct WindowPaceSnapshot { /// pace on any card lane, whatever the window's length. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub reset_window: bool, + /// Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). + /// Absent outside monthly windows and when usage or the reset is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daily_target: Option, +} + +/// Remaining allowance divided by the local calendar days left, in the +/// allowance's own unit (`%`, `credits` or `requests`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyTargetSnapshot { + pub amount: f64, + pub unit: String, +} + +impl From for DailyTargetSnapshot { + fn from(target: codexbar::core::MonthlyDailyTarget) -> Self { + Self { + amount: target.amount, + unit: target.unit, + } + } } /// Where a window is rendered. Reset-window pace applies to the card's @@ -84,7 +106,13 @@ impl WindowPaceSnapshot { default_window_minutes, work_days, )?; - Some(Self::from_pace(&pace, false)) + let mut snapshot = Self::from_pace(&pace, false); + snapshot.daily_target = daily_target( + &codexbar::core::ProviderPaceCapability::UNSUPPORTED, + window, + now, + ); + Some(snapshot) } /// Lane pace with the provider's reset-window capability applied. @@ -101,7 +129,9 @@ impl WindowPaceSnapshot { } let (pace, reset_window) = lane_usage_pace(id, lane, window, default_window_minutes, now, work_days)?; - Some(Self::from_pace(&pace, reset_window)) + let mut snapshot = Self::from_pace(&pace, reset_window); + snapshot.daily_target = daily_target(&id.pace_capability(), window, now); + Some(snapshot) } fn from_pace(pace: &codexbar::core::UsagePace, reset_window: bool) -> Self { @@ -113,10 +143,24 @@ impl WindowPaceSnapshot { eta_seconds: pace.eta_seconds, will_last_to_reset: pace.will_last_to_reset, reset_window, + daily_target: None, } } } +/// Daily target of a monthly window in local calendar days (the Mac uses +/// `Calendar.current`). Upstream `monthlyTargetMetrics` reads the same +/// window, so the target never estimates today's consumption. +fn daily_target( + capability: &codexbar::core::ProviderPaceCapability, + window: &RateWindow, + now: DateTime, +) -> Option { + capability + .monthly_daily_target(window, now, &chrono::Local) + .map(DailyTargetSnapshot::from) +} + #[cfg(test)] mod tests { use super::*; @@ -167,6 +211,7 @@ mod tests { eta_seconds: None, will_last_to_reset: true, reset_window: false, + daily_target: None, }) ); } @@ -547,6 +592,68 @@ mod tests { assert!(!cached.pace_authoritative); } + /// A calendar-month credit allowance carries its daily target on the card + /// lane: 200 remaining credits over the 14 local days to the Mar 1 reset. + /// Local-day counting varies by zone, so the bound allows one day either way. + #[test] + fn monthly_credit_allowance_carries_a_daily_target_on_the_card_lane() { + let window = monthly(20.0, Some(43_200)).with_quota(250.0, "credits"); + let pace = WindowPaceSnapshot::for_lane( + ProviderId::Notion, + PaceLane::Card, + &window, + WEEKLY_FALLBACK_MINUTES, + february_now(), + None, + ) + .expect("pace"); + + let target = pace.daily_target.expect("daily target"); + assert_eq!(target.unit, "credits"); + let days = 200.0 / target.amount; + assert!((13.0..=15.0).contains(&days), "{days}"); + } + + #[test] + fn weekly_window_has_no_daily_target() { + // Reset five days out, inside the seven-day window. + let reset = Utc.with_ymd_and_hms(2026, 2, 20, 0, 0, 0).unwrap(); + let window = RateWindow::with_details(20.0, Some(10_080), Some(reset), None) + .with_quota(250.0, "credits"); + let pace = WindowPaceSnapshot::for_lane( + ProviderId::Notion, + PaceLane::Card, + &window, + WEEKLY_FALLBACK_MINUTES, + february_now(), + None, + ) + .expect("pace"); + + assert_eq!(pace.daily_target, None); + } + + /// A provider that opts out of pace gets no daily target either, so the + /// target follows the same pace visibility as the rest of the lane. + #[test] + fn non_authoritative_monthly_allowance_has_no_daily_target() { + let metadata = instantiate_provider(ProviderId::Notion).metadata().clone(); + let window = monthly(20.0, Some(43_200)).with_quota(250.0, "credits"); + let result = ProviderFetchResult::new(UsageSnapshot::new(window), "web") + .with_non_authoritative_pace(); + + let snap = ProviderUsageSnapshot::from_fetch_result_at( + ProviderId::Notion, + &metadata, + &result, + None, + None, + february_now(), + ); + + assert_eq!(snap.primary.pace, None); + } + #[test] fn exhausted_reset_window_has_no_pace() { let usage = UsageSnapshot::new(monthly(100.0, None)); diff --git a/apps/desktop-tauri/src/i18n/keyGroups/core.ts b/apps/desktop-tauri/src/i18n/keyGroups/core.ts index 860e6b7169e..24706f5676c 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/core.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/core.ts @@ -249,6 +249,9 @@ export const CORE_LOCALE_KEYS = [ "PaceInReserve", "PaceInDeficit", "PaceLastsUntilReset", + "PaceDailyTargetPercent", + "PaceDailyTargetCredits", + "PaceDailyTargetRequests", "PaceRunsOutIn", "PaceRunsOutNow", "PaceProjectedEmptyIn", diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts index 86b2b24131e..6bcf37bd11b 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.test.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -510,3 +510,50 @@ describe("metricRowPresentation bar", () => { ).toBe("63%"); }); }); + +describe("monthly daily target", () => { + // A calendar-month allowance on the secondary lane with a synthetic target. + function monthly(dailyTarget: WindowPaceSnapshot["dailyTarget"]): RateWindowSnapshot { + return rateWindow({ + usedPercent: 79, + remainingPercent: 21, + windowMinutes: 43_200, + pace: pace({ dailyTarget }), + }); + } + + it("shows a credit allowance's per-day share in the allowance's unit", () => { + const row = metricRowPresentation( + input({ lane: "secondary", snap: monthly({ amount: 21.0909, unit: "credits" }) }), + en, + ); + expect(row.metaText).toContain("≈ 21.09 credits / day to last until reset"); + }); + + it("formats percent, request and unknown units", () => { + const text = (dailyTarget: WindowPaceSnapshot["dailyTarget"]) => + metricRowPresentation(input({ lane: "secondary", snap: monthly(dailyTarget) }), en).metaText ?? ""; + + expect(text({ amount: 2.46, unit: "%" })).toContain("≈ 2.5% / day to last until reset"); + expect(text({ amount: 10.714, unit: "requests" })).toContain( + "≈ 10.71 requests / day to last until reset", + ); + expect(text({ amount: 5, unit: "tokens" })).not.toContain("/ day"); + }); + + it("follows the pace setting and hides on compact rows", () => { + const target = { amount: 21.09, unit: "credits" }; + expect( + metricRowPresentation(input({ lane: "secondary", snap: monthly(target), paceEnabled: false }), en) + .metaText, + ).toBeNull(); + expect( + metricRowPresentation(input({ lane: "secondary", snap: monthly(target), compact: true }), en).metaText, + ).toBeNull(); + }); + + it("shows nothing when the window carries no target", () => { + const row = metricRowPresentation(input({ lane: "secondary", snap: monthly(null) }), en); + expect(row.metaText ?? "").not.toContain("/ day"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 10e333fd7b2..36333601f35 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -1,6 +1,7 @@ import { normalizeResetDescription } from "../hooks/useFormattedResetTime"; import type { LocaleKey } from "../i18n/keys"; import type { + DailyTargetSnapshot, PaceStage, RateWindowSnapshot, WindowPaceSnapshot, @@ -267,6 +268,26 @@ function paceMetaText(pace: WindowPaceSnapshot, kind: PaceKind, t: Translate): s return right ? `${left} · ${right}` : left; } +const DAILY_TARGET_KEYS: Record = { + "%": "PaceDailyTargetPercent", + credits: "PaceDailyTargetCredits", + requests: "PaceDailyTargetRequests", +}; + +/** + * Upstream `UsagePaceText.dailyTarget`: the per-day share in the allowance's + * own unit. Units without a key (for example a currency) show no target. + */ +function dailyTargetText(target: DailyTargetSnapshot, t: Translate): string | null { + const key = DAILY_TARGET_KEYS[target.unit]; + if (!key || !Number.isFinite(target.amount)) return null; + const digits = target.unit === "%" ? 1 : 2; + const amount = new Intl.NumberFormat(intlLocale(t), { maximumFractionDigits: digits }).format( + Math.max(0, target.amount), + ); + return fill(t(key), amount); +} + function normalizedMarkerPercents(values: number[]): number[] { const kept: number[] = []; for (const value of values.map(clampPercent)) { @@ -342,6 +363,15 @@ export function metricRowPresentation(input: MetricRowInput, t: Translate): Metr input.showResetWhenExhausted && snap.isExhausted && resetTarget > input.now && input.resetText !== null; const visible = visiblePace(input); const markers = barMarkers(input); + // The daily target shares the pace setting and the non-compact meta line. + const dailyTarget = + input.paceEnabled && !input.compact && snap.pace?.dailyTarget + ? dailyTargetText(snap.pace.dailyTarget, t) + : null; + const metaParts = [ + visible && !input.compact ? paceMetaText(visible.pace, visible.kind, t) : null, + dailyTarget, + ].filter((part): part is string => part !== null); const valuePercent = Math.round(shown); const pacePercent = visible && PACE_DIRECTION[visible.pace.stage] !== "onPace" @@ -352,7 +382,7 @@ export function metricRowPresentation(input: MetricRowInput, t: Translate): Metr ? null : percentLabel(shown, t(showAsUsed ? "PanelUsedSuffix" : "PanelLeftSuffix")), resetText: input.compact && !replacesPercent ? null : input.resetText, - metaText: visible && !input.compact ? paceMetaText(visible.pace, visible.kind, t) : null, + metaText: metaParts.length > 0 ? metaParts.join(" · ") : null, bar: { fillPercent: renderedFillPercent(shown), valuePercent, diff --git a/apps/desktop-tauri/src/types/bridge/usage.ts b/apps/desktop-tauri/src/types/bridge/usage.ts index f066e9a61ca..058a024da79 100644 --- a/apps/desktop-tauri/src/types/bridge/usage.ts +++ b/apps/desktop-tauri/src/types/bridge/usage.ts @@ -89,6 +89,17 @@ export interface WindowPaceSnapshot { * pace whatever the window's length. Omitted when false. */ resetWindow?: boolean; + /** + * Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). + * Omitted outside monthly windows and when usage or the reset is unknown. + */ + dailyTarget?: DailyTargetSnapshot | null; +} + +/** Remaining allowance over the local days left, in `unit` (`%`, `credits` or `requests`). */ +export interface DailyTargetSnapshot { + amount: number; + unit: string; } export interface PaceSnapshot { diff --git a/rust/src/core/pace_capability.rs b/rust/src/core/pace_capability.rs index 0fea5328bfb..d536d90d00a 100644 --- a/rust/src/core/pace_capability.rs +++ b/rust/src/core/pace_capability.rs @@ -13,7 +13,7 @@ //! dispatch is `provider_factory::pace_capability`, reached through //! [`super::ProviderId::pace_capability`]. -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use super::{RateWindow, UsagePace}; @@ -170,6 +170,54 @@ impl ProviderPaceCapability { work_days, ) } + + /// Per-day share of a monthly allowance (upstream + /// `UsageMenuCardView.Model.monthlyTargetMetrics`). The window must be a + /// usable, non-regenerating monthly window: its length is the inferred + /// monthly duration, or 28 to 31 days. A window without an absolute + /// allowance is measured in percent. A reported allowance must be a + /// positive finite number in `credits` or `requests`; other units have no + /// target. Usage that the provider did not report has no target. + pub fn monthly_daily_target( + &self, + window: &RateWindow, + now: DateTime, + tz: &Tz, + ) -> Option { + if window.is_informational || window.reset_regenerates || !window.usage_known() { + return None; + } + let monthly = self.uses_inferred_monthly_duration(window) + || window.window_minutes.is_some_and(|minutes| { + (28.0..=31.0).contains(&(f64::from(minutes) / 1440.0).round()) + }); + if !monthly { + return None; + } + let (limit, unit) = match (window.quota_limit, window.quota_unit.as_deref()) { + (None, _) => (100.0, "%"), + (Some(limit), Some(unit @ ("credits" | "requests"))) + if limit.is_finite() && limit > 0.0 => + { + (limit, unit) + } + _ => return None, + }; + let remaining = limit * window.remaining_percent() / 100.0; + let amount = UsagePace::daily_target_in(remaining, window.resets_at, now, tz)?; + Some(MonthlyDailyTarget { + amount, + unit: unit.to_string(), + }) + } +} + +/// A monthly allowance's per-day share, in the allowance's own unit +/// (`%`, `credits` or `requests`). +#[derive(Debug, Clone, PartialEq)] +pub struct MonthlyDailyTarget { + pub amount: f64, + pub unit: String, } #[cfg(test)] @@ -186,6 +234,133 @@ mod tests { RateWindow::with_details(used, minutes, Some(resets_at), None) } + /// 2026-02-15 12:00 UTC with the Mar 1 reset: 14 whole local days remain. + fn daily_now() -> DateTime { + at(2026, 2, 15, 12) + } + + fn daily_target_of( + window: &RateWindow, + capability: ProviderPaceCapability, + ) -> Option { + capability.monthly_daily_target(window, daily_now(), &chrono::Utc) + } + + fn monthly_reset(used: f64) -> RateWindow { + window( + used, + Some(MONTHLY_WINDOW_SENTINEL_MINUTES), + at(2026, 3, 1, 0), + ) + } + + #[test] + fn percent_only_monthly_window_targets_remaining_percent_per_day() { + let target = daily_target_of( + &monthly_reset(58.0), + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("daily target"); + assert_eq!(target.unit, "%"); + assert!((target.amount - 3.0).abs() < 1e-9); + } + + #[test] + fn credit_and_request_allowances_keep_their_unit() { + let credits = monthly_reset(40.0).with_quota(250.0, "credits"); + let target = daily_target_of( + &credits, + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("credit target"); + assert_eq!(target.unit, "credits"); + assert!((target.amount - 150.0 / 14.0).abs() < 1e-9); + + let requests = monthly_reset(0.0).with_quota(150.0, "requests"); + let target = daily_target_of(&requests, ProviderPaceCapability::UNSUPPORTED) + .expect("request target"); + assert_eq!(target.unit, "requests"); + assert!((target.amount - 150.0 / 14.0).abs() < 1e-9); + } + + #[test] + fn length_of_twenty_eight_to_thirty_one_days_counts_as_monthly() { + let twenty_eight_days = window(0.0, Some(28 * 1440), at(2026, 3, 1, 0)); + assert!(daily_target_of(&twenty_eight_days, ProviderPaceCapability::UNSUPPORTED).is_some()); + let twenty_seven_days = window(0.0, Some(27 * 1440), at(2026, 3, 1, 0)); + assert!(daily_target_of(&twenty_seven_days, ProviderPaceCapability::UNSUPPORTED).is_none()); + } + + #[test] + fn weekly_and_session_windows_have_no_daily_target() { + let weekly = window(10.0, Some(10_080), at(2026, 2, 20, 0)); + assert!( + daily_target_of(&weekly, ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW).is_none() + ); + let session = window(10.0, Some(300), at(2026, 2, 15, 17)); + assert!( + daily_target_of( + &session, + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW + ) + .is_none() + ); + } + + #[test] + fn unknown_usage_informational_regenerating_or_missing_reset_has_no_target() { + let capability = ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW; + assert!( + daily_target_of(&monthly_reset(10.0).with_usage_known(false), capability).is_none() + ); + let mut informational = monthly_reset(10.0); + informational.is_informational = true; + assert!(daily_target_of(&informational, capability).is_none()); + assert!( + daily_target_of(&monthly_reset(10.0).with_regenerating_reset(), capability).is_none() + ); + let missing_reset = + RateWindow::with_details(10.0, Some(MONTHLY_WINDOW_SENTINEL_MINUTES), None, None); + assert!(daily_target_of(&missing_reset, capability).is_none()); + let expired = window( + 10.0, + Some(MONTHLY_WINDOW_SENTINEL_MINUTES), + at(2026, 2, 10, 0), + ); + assert!(daily_target_of(&expired, capability).is_none()); + } + + #[test] + fn unit_less_or_unsupported_allowances_have_no_target() { + let capability = ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW; + let mut unitless = monthly_reset(10.0); + unitless.quota_limit = Some(100.0); + assert!(daily_target_of(&unitless, capability).is_none()); + assert!( + daily_target_of(&monthly_reset(10.0).with_quota(100.0, "tokens"), capability).is_none() + ); + assert!( + daily_target_of( + &monthly_reset(10.0).with_quota(f64::INFINITY, "credits"), + capability + ) + .is_none() + ); + assert!( + daily_target_of(&monthly_reset(10.0).with_quota(0.0, "credits"), capability).is_none() + ); + } + + #[test] + fn exhausted_monthly_allowance_has_a_zero_target() { + let target = daily_target_of( + &monthly_reset(125.0), + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("zero target"); + assert_eq!(target.amount, 0.0); + } + fn resolved_minutes(minutes: Option, resets_at: DateTime) -> Option { ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW .resolved_reset_window_for_pace(&window(10.0, minutes, resets_at)) diff --git a/rust/src/core/rate_window.rs b/rust/src/core/rate_window.rs index 1be153f763c..bdd41dfef03 100755 --- a/rust/src/core/rate_window.rs +++ b/rust/src/core/rate_window.rs @@ -116,6 +116,17 @@ pub struct RateWindow { /// becoming an exported quota measurement. #[serde(default = "rate_window_usage_known_default", skip_serializing)] pub(crate) usage_known: bool, + + /// Absolute allowance reported alongside the percentage (for example 250 + /// credits). Upstream `RateWindow.quotaLimit`; absent for percent-only + /// windows, and kept out of payloads unless set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quota_limit: Option, + + /// Unit of [`Self::quota_limit`]: `credits`, `requests`, or an ISO currency + /// code. Upstream `RateWindow.quotaUnit`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quota_unit: Option, } fn rate_window_usage_known_default() -> bool { @@ -135,6 +146,8 @@ impl RateWindow { reset_regenerates: false, hidden_on_card: false, usage_known: true, + quota_limit: None, + quota_unit: None, } } @@ -178,6 +191,8 @@ impl RateWindow { reset_regenerates: false, hidden_on_card: false, usage_known: true, + quota_limit: None, + quota_unit: None, } } @@ -187,6 +202,13 @@ impl RateWindow { self } + /// Record the absolute allowance behind the percentage, in `unit`. + pub fn with_quota(mut self, limit: f64, unit: impl Into) -> Self { + self.quota_limit = Some(limit); + self.quota_unit = Some(unit.into()); + self + } + /// The reset is the next quota regeneration ("Regenerates in ..."). pub fn with_regenerating_reset(mut self) -> Self { self.reset_regenerates = true; @@ -338,6 +360,27 @@ mod tests { assert!(!window.usage_known()); } + #[test] + fn quota_allowance_serializes_only_when_reported() { + let plain = RateWindow::with_details(10.0, Some(43_200), None, None); + let plain_json = serde_json::to_value(&plain).expect("rate window JSON"); + assert!(plain_json.get("quota_limit").is_none()); + assert!(plain_json.get("quota_unit").is_none()); + + let quoted = plain.with_quota(250.0, "credits"); + let json = serde_json::to_value("ed).expect("rate window JSON"); + assert_eq!(json["quota_limit"], serde_json::json!(250.0)); + assert_eq!(json["quota_unit"], serde_json::json!("credits")); + + let restored: RateWindow = serde_json::from_value(json).expect("round trip"); + assert_eq!(restored.quota_limit, Some(250.0)); + assert_eq!(restored.quota_unit.as_deref(), Some("credits")); + let legacy: RateWindow = + serde_json::from_value(serde_json::json!({ "used_percent": 5.0 })).expect("legacy"); + assert_eq!(legacy.quota_limit, None); + assert_eq!(legacy.quota_unit, None); + } + #[test] fn internal_usage_known_metadata_is_not_serialized() { let window = RateWindow::with_details(0.0, Some(300), None, None).with_usage_known(false); diff --git a/rust/src/core/usage_pace.rs b/rust/src/core/usage_pace.rs index 16c01714ad2..bfd3dcede3b 100755 --- a/rust/src/core/usage_pace.rs +++ b/rust/src/core/usage_pace.rs @@ -3,7 +3,7 @@ //! Calculates whether the user is On Track, Ahead, or Behind their usage quota //! based on elapsed time and consumption rate. -use chrono::{DateTime, Datelike, Duration, Local, LocalResult, TimeZone, Utc}; +use chrono::{DateTime, Datelike, Duration, Local, LocalResult, NaiveDate, TimeZone, Utc}; use super::RateWindow; @@ -106,6 +106,41 @@ impl UsagePace { Self::weekly_in(window, now, default_window_minutes, work_days, &Local) } + /// Per-day share of `remaining` until `resets_at` in the machine's local + /// time zone. See [`UsagePace::daily_target_in`]. + pub fn daily_target( + remaining: f64, + resets_at: Option>, + now: DateTime, + ) -> Option { + Self::daily_target_in(remaining, resets_at, now, &Local) + } + + /// Upstream `UsagePace.dailyTarget`: the remaining allowance divided by the + /// local calendar days that are left, counting today and any partial reset + /// day. A reset exactly at local midnight adds no partial day, and today + /// always counts, so the target is at least `remaining`. It never estimates + /// today's consumption. Returns `None` for a negative or non-finite + /// remainder, or a reset that is missing or not after `now`. + pub fn daily_target_in( + remaining: f64, + resets_at: Option>, + now: DateTime, + tz: &Tz, + ) -> Option { + if !remaining.is_finite() || remaining < 0.0 { + return None; + } + let resets_at = resets_at.filter(|at| *at > now)?; + let reset_date = resets_at.with_timezone(tz).date_naive(); + let reset_day_start = local_midnight_on(reset_date, tz)?; + let today = now.with_timezone(tz).date_naive(); + let whole_days = (reset_date - today).num_days(); + let partial_day = i64::from(resets_at > reset_day_start); + let day_count = u32::try_from((whole_days + partial_day).max(1)).ok()?; + Some(remaining / f64::from(day_count)) + } + /// [`UsagePace::weekly`] with an explicit time zone for local day /// boundaries (upstream passes `Calendar.current`; tests inject one). pub fn weekly_in( @@ -363,7 +398,13 @@ fn seconds_to_millis(seconds: f64) -> Option { /// quarter hour after it is used, as Foundation's `startOfDay` does. fn next_day_boundary(instant: DateTime, tz: &Tz) -> Option> { let next_date = instant.with_timezone(tz).date_naive().succ_opt()?; - let midnight = next_date.and_hms_opt(0, 0, 0)?; + local_midnight_on(next_date, tz) +} + +/// Start of local calendar day `date`, using the first existing local quarter +/// hour when midnight is skipped (see [`next_day_boundary`]). +fn local_midnight_on(date: NaiveDate, tz: &Tz) -> Option> { + let midnight = date.and_hms_opt(0, 0, 0)?; (0..=180).step_by(15).find_map(|offset_minutes| { let candidate = midnight + Duration::minutes(offset_minutes); match tz.from_local_datetime(&candidate) { @@ -431,6 +472,98 @@ mod tests { assert!(pace.delta_percent > 0.0); } + /// Upstream `MonthlyPacingTargetTests` daily-target cases, with the local + /// calendar injected so the results do not depend on the machine. + mod daily_target { + use super::super::*; + use chrono::{FixedOffset, TimeZone, Utc}; + + fn utc() -> FixedOffset { + FixedOffset::east_opt(0).unwrap() + } + + fn at(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(year, month, day, hour, minute, 0) + .unwrap() + } + + /// 2026-02-15 12:00 UTC: 14 whole days remain until the Mar 1 reset. + fn now() -> DateTime { + at(2026, 2, 15, 12, 0) + } + + #[test] + fn midnight_reset_counts_only_the_whole_days_before_it() { + let target = + UsagePace::daily_target_in(60.0, Some(at(2026, 3, 1, 0, 0)), now(), &utc()); + assert!(target.is_some_and(|value| (value - 60.0 / 14.0).abs() < 1e-9)); + } + + #[test] + fn partial_reset_day_counts_toward_the_target() { + let target = + UsagePace::daily_target_in(60.0, Some(at(2026, 3, 1, 12, 0)), now(), &utc()); + assert!(target.is_some_and(|value| (value - 4.0).abs() < 1e-9)); + } + + #[test] + fn today_counts_when_the_reset_is_later_today_or_tomorrow_midnight() { + let later_today = + UsagePace::daily_target_in(42.0, Some(at(2026, 2, 15, 18, 0)), now(), &utc()); + assert_eq!(later_today, Some(42.0)); + let tomorrow_midnight = + UsagePace::daily_target_in(42.0, Some(at(2026, 2, 16, 0, 0)), now(), &utc()); + assert_eq!(tomorrow_midnight, Some(42.0)); + } + + #[test] + fn missing_expired_or_invalid_inputs_have_no_target() { + assert_eq!(UsagePace::daily_target_in(10.0, None, now(), &utc()), None); + assert_eq!( + UsagePace::daily_target_in(10.0, Some(now()), now(), &utc()), + None + ); + let past = now() - chrono::Duration::hours(1); + assert_eq!( + UsagePace::daily_target_in(10.0, Some(past), now(), &utc()), + None + ); + let future = Some(at(2026, 3, 1, 0, 0)); + assert_eq!( + UsagePace::daily_target_in(-1.0, future, now(), &utc()), + None + ); + assert_eq!( + UsagePace::daily_target_in(f64::NAN, future, now(), &utc()), + None + ); + assert_eq!( + UsagePace::daily_target_in(f64::INFINITY, future, now(), &utc()), + None + ); + } + + #[test] + fn exhausted_allowance_has_a_zero_target() { + let target = UsagePace::daily_target_in(0.0, Some(at(2026, 3, 1, 0, 0)), now(), &utc()); + assert_eq!(target, Some(0.0)); + } + + #[test] + fn local_calendar_days_span_a_daylight_saving_change() { + let los_angeles = chrono_tz::America::Los_Angeles; + // Mar 7 23:30 PST to Mar 9 00:30 PDT: three local dates, one day of hours. + let now = at(2026, 3, 8, 7, 30); + let reset = at(2026, 3, 9, 7, 30); + let target = UsagePace::daily_target_in(30.0, Some(reset), now, &los_angeles); + assert!(target.is_some_and(|value| (value - 10.0).abs() < 1e-9)); + // Local midnight on Mar 9 is 07:00 UTC: Mar 7 and Mar 8 remain, no partial day. + let midnight = at(2026, 3, 9, 7, 0); + let target = UsagePace::daily_target_in(30.0, Some(midnight), now, &los_angeles); + assert!(target.is_some_and(|value| (value - 15.0).abs() < 1e-9)); + } + } + // Ported from upstream v0.70.0 Tests/CodexBarTests/UsagePaceTests.swift. // Literal expected values are the oracle. `speedMultiplierToReset` and // `runOutProbability` assertions are omitted: Windows has neither field. diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 549b474e91d..357085d351f 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -480,6 +480,9 @@ locale_keys! { PaceInReserve, PaceInDeficit, PaceLastsUntilReset, + PaceDailyTargetPercent, + PaceDailyTargetCredits, + PaceDailyTargetRequests, PaceRunsOutIn, PaceRunsOutNow, PaceProjectedEmptyIn, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index d1c724b93a0..90a10dc144c 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -241,6 +241,9 @@ PaceOnPace = On pace PaceInReserve = { "{}" }% in reserve PaceInDeficit = { "{}" }% in deficit PaceLastsUntilReset = Lasts until reset +PaceDailyTargetPercent = ≈ { "{}" }% / day to last until reset +PaceDailyTargetCredits = ≈ { "{}" } credits / day to last until reset +PaceDailyTargetRequests = ≈ { "{}" } requests / day to last until reset PaceRunsOutIn = Runs out in { "{}" } PaceRunsOutNow = Runs out now PaceProjectedEmptyIn = Projected empty in { "{}" } diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 67b3e9daa4b..479f3447ae3 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -209,6 +209,9 @@ PaceOnPace = Al ritmo previsto PaceInReserve = { "{}" }% de reserva PaceInDeficit = { "{}" }% de déficit PaceLastsUntilReset = Dura hasta el reinicio +PaceDailyTargetPercent = ≈ { "{}" }% por día para durar hasta el reinicio +PaceDailyTargetCredits = ≈ { "{}" } créditos por día para durar hasta el reinicio +PaceDailyTargetRequests = ≈ { "{}" } solicitudes por día para durar hasta el reinicio PaceRunsOutIn = Se agota en { "{}" } PaceRunsOutNow = Se agota ahora PaceProjectedEmptyIn = Agotamiento previsto en { "{}" } diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index dac8b77dfb3..8c8249005c9 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -209,6 +209,9 @@ PaceOnPace = 想定ペース PaceInReserve = { "{}" }% 余裕 PaceInDeficit = { "{}" }% 不足 PaceLastsUntilReset = リセットまで持ちます +PaceDailyTargetPercent = 1日あたり約 { "{}" }%(リセットまで持つ目安) +PaceDailyTargetCredits = 1日あたり約 { "{}" } クレジット(リセットまで持つ目安) +PaceDailyTargetRequests = 1日あたり約 { "{}" } リクエスト(リセットまで持つ目安) PaceRunsOutIn = { "{}" } 後に使い切る見込み PaceRunsOutNow = まもなく使い切ります PaceProjectedEmptyIn = { "{}" } 後に枯渇する見込み diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 144ab686a4c..10acfc1e49f 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -209,6 +209,9 @@ PaceOnPace = 정상 속도 PaceInReserve = { "{}" }% 여유 PaceInDeficit = { "{}" }% 부족 PaceLastsUntilReset = 리셋까지 지속 +PaceDailyTargetPercent = 하루 약 { "{}" }%, 리셋까지 유지 가능 +PaceDailyTargetCredits = 하루 약 { "{}" } 크레딧, 리셋까지 유지 가능 +PaceDailyTargetRequests = 하루 약 { "{}" } 요청, 리셋까지 유지 가능 PaceRunsOutIn = { "{}" } 후 소진 PaceRunsOutNow = 지금 소진 PaceProjectedEmptyIn = { "{}" } 후 소진 예상 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 8d98f535491..ddca9366e3a 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -237,6 +237,9 @@ PaceOnPace = No ritmo PaceInReserve = { "{}" }% em reserva PaceInDeficit = { "{}" }% em déficit PaceLastsUntilReset = Dura até a renovação +PaceDailyTargetPercent = ≈ { "{}" }% por dia para durar até a renovação +PaceDailyTargetCredits = ≈ { "{}" } créditos por dia para durar até a renovação +PaceDailyTargetRequests = ≈ { "{}" } solicitações por dia para durar até a renovação PaceRunsOutIn = Esgota em { "{}" } PaceRunsOutNow = Esgota agora PaceProjectedEmptyIn = Esgotamento previsto em { "{}" } diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index aca02c77ab5..2f7bbf42865 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -241,6 +241,9 @@ PaceOnPace = В темпе PaceInReserve = { "{}" }% в резерве PaceInDeficit = { "{}" }% в дефиците PaceLastsUntilReset = Длится до сброса +PaceDailyTargetPercent = ≈ { "{}" }% в день, хватит до сброса +PaceDailyTargetCredits = ≈ { "{}" } кредитов в день, хватит до сброса +PaceDailyTargetRequests = ≈ { "{}" } запросов в день, хватит до сброса PaceRunsOutIn = Закончится через { "{}" } PaceRunsOutNow = Сейчас заканчивается PaceProjectedEmptyIn = По прогнозу закончится через { "{}" } diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 872146dc7cd..4432f6a71be 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -241,6 +241,9 @@ PaceOnPace = Beklenen tempoda PaceInReserve = %{ "{}" } rezervde PaceInDeficit = %{ "{}" } açıkta PaceLastsUntilReset = Sıfırlamaya kadar sürer +PaceDailyTargetPercent = ≈ günde { "{}" }%, sıfırlamaya kadar yeter +PaceDailyTargetCredits = ≈ günde { "{}" } kredi, sıfırlamaya kadar yeter +PaceDailyTargetRequests = ≈ günde { "{}" } istek, sıfırlamaya kadar yeter PaceRunsOutIn = { "{}" } içinde biter PaceRunsOutNow = Şimdi biter PaceProjectedEmptyIn = { "{}" } içinde tükenmesi tahmin ediliyor diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 32ec3505db9..551f812327d 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -241,6 +241,9 @@ PaceOnPace = В темпі PaceInReserve = { "{}" }% в резерві PaceInDeficit = { "{}" }% в дефіциті PaceLastsUntilReset = Вистачить до скидання +PaceDailyTargetPercent = ≈ { "{}" }% на день, вистачить до скидання +PaceDailyTargetCredits = ≈ { "{}" } кредитів на день, вистачить до скидання +PaceDailyTargetRequests = ≈ { "{}" } запитів на день, вистачить до скидання PaceRunsOutIn = Закінчується за { "{}" } PaceRunsOutNow = Зараз закінчується PaceProjectedEmptyIn = Передбачається порожній у { "{}" } diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 7cc45acb643..b9489cc5506 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -209,6 +209,9 @@ PaceOnPace = 节奏正常 PaceInReserve = 余量 { "{}" }% PaceInDeficit = 超额 { "{}" }% PaceLastsUntilReset = 持续到重置 +PaceDailyTargetPercent = 每天约 { "{}" }%,可维持到重置 +PaceDailyTargetCredits = 每天约 { "{}" } 点数,可维持到重置 +PaceDailyTargetRequests = 每天约 { "{}" } 次请求,可维持到重置 PaceRunsOutIn = 预计 { "{}" } 后耗尽 PaceRunsOutNow = 即将耗尽 PaceProjectedEmptyIn = 预计 { "{}" } 后耗尽 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 97caa0fdab4..572b00672db 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -209,6 +209,9 @@ PaceOnPace = 用量正常 PaceInReserve = 比目前進度少用 { "{}" }% PaceInDeficit = 比目前進度多用 { "{}" }% PaceLastsUntilReset = 持續到重置 +PaceDailyTargetPercent = 每天約 { "{}" }%,可維持到重置 +PaceDailyTargetCredits = 每天約 { "{}" } 點數,可維持到重置 +PaceDailyTargetRequests = 每天約 { "{}" } 次請求,可維持到重置 PaceRunsOutIn = { "{}" } 後用完 PaceRunsOutNow = 現在用完 PaceProjectedEmptyIn = 預估 { "{}" } 後用完 diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index 5bc805ee1b6..742ba034076 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -487,6 +487,8 @@ impl UsableQuota { reset_regenerates: false, hidden_on_card: false, usage_known: true, + quota_limit: None, + quota_unit: None, } } } diff --git a/rust/src/providers/futureagi/mod.rs b/rust/src/providers/futureagi/mod.rs index 1c1707e6798..4af0915379f 100644 --- a/rust/src/providers/futureagi/mod.rs +++ b/rust/src/providers/futureagi/mod.rs @@ -295,8 +295,10 @@ fn parse_overview(body: &[u8]) -> Result { let used = amount(meter.get("current_usage"))?; let allowance = amount(meter.get("free_allowance"))?; if allowance > 0.0 { + // Upstream `futureagi.ts` reports the allowance with its display unit. let window = - RateWindow::with_details(percent(used, allowance), window_minutes, Some(end), None); + RateWindow::with_details(percent(used, allowance), window_minutes, Some(end), None) + .with_quota(allowance, unit.clone()); match key { PRIMARY_KEY => primary = Some(window), SECONDARY_KEY => secondary = Some(window), diff --git a/rust/src/providers/futureagi/tests.rs b/rust/src/providers/futureagi/tests.rs index 22bb94697c7..43e72f7bace 100644 --- a/rust/src/providers/futureagi/tests.rs +++ b/rust/src/providers/futureagi/tests.rs @@ -113,6 +113,11 @@ async fn documented_usage_preserves_allowances_overages_and_billing_dates() { let secondary = result.usage.secondary.as_ref().expect("gateway window"); assert_eq!(secondary.used_percent, 100.0); assert_eq!(secondary.window_minutes, Some(44_640)); + // Upstream `futureagi.ts`: the allowance and its display unit ride along. + assert_eq!(primary.quota_limit, Some(2000.0)); + assert_eq!(primary.quota_unit.as_deref(), Some("credits")); + assert_eq!(secondary.quota_limit, Some(100_000.0)); + assert_eq!(secondary.quota_unit.as_deref(), Some("requests")); assert_eq!( rows_in(&result, USAGE_SECTION), diff --git a/rust/src/providers/notion/mod.rs b/rust/src/providers/notion/mod.rs index b7363d22c1e..4ba31538b67 100644 --- a/rust/src/providers/notion/mod.rs +++ b/rust/src/providers/notion/mod.rs @@ -289,7 +289,10 @@ fn build_usage_snapshot( account: Option<&NotionAccount>, now: DateTime, ) -> Result { + // Upstream `NotionProvider` reports each allowance in credits (`quotaUnit`), + // so a monthly billing window can show its per-day target. let rolling = rate_limit.window.as_ref().and_then(|window| { + let limit = window.limit?; percent(window.used, window.limit).map(|used_percent| { RateWindow::with_details( used_percent, @@ -297,6 +300,7 @@ fn build_usage_snapshot( rolling_reset(rate_limit.resets_in_seconds, now), None, ) + .with_quota(limit, "credits") }) }); @@ -304,6 +308,7 @@ fn build_usage_snapshot( .billing_period_window .as_ref() .and_then(|billing| { + let limit = billing.limit?; percent(billing.used, billing.limit).map(|used_percent| { RateWindow::with_details( used_percent, @@ -311,6 +316,7 @@ fn build_usage_snapshot( date_from_milliseconds(billing.period_end_ms), None, ) + .with_quota(limit, "credits") }) }); @@ -744,6 +750,11 @@ mod tests { secondary.resets_at, date_from_milliseconds(Some(1_735_689_600_000.0)) ); + // Upstream `NotionAllowanceTests`: both allowances are credits. + assert_eq!(snap.primary.quota_limit, Some(100.0)); + assert_eq!(snap.primary.quota_unit.as_deref(), Some("credits")); + assert_eq!(secondary.quota_limit, Some(1000.0)); + assert_eq!(secondary.quota_unit.as_deref(), Some("credits")); // Upstream `NotionProviderDescriptor.swift:32`: "Monthly". assert_eq!( snap.secondary_label_key, From fe3984e0fd10cde4356fbe5c0006f94cfa42438e Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 02:33:44 +0700 Subject: [PATCH 2/5] Show the daily target independently of pace --- .../scripts/proof-scenarios.json | 9 +++ .../src-tauri/src/commands/bridge.rs | 10 +++ .../src-tauri/src/commands/bridge/pace.rs | 75 +++++++++---------- .../src-tauri/src/proof_harness.rs | 8 +- .../src/lib/metricRowModel.test.ts | 14 +++- apps/desktop-tauri/src/lib/metricRowModel.ts | 4 +- .../src/proof/fixtures/scenarios.ts | 18 +++++ apps/desktop-tauri/src/types/bridge/usage.ts | 11 +-- rust/src/locale/tr-TR.ftl | 2 +- rust/src/locale/zh-CN.ftl | 2 +- rust/src/locale/zh-TW.ftl | 2 +- 11 files changed, 102 insertions(+), 53 deletions(-) diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index 99ca76f18d5..6fb06fb46ac 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -497,6 +497,15 @@ { "screenshot": "flyout-kimi-bonus-credits", "clip": "panel" } ] }, + { + "name": "flyout-copilot-daily-target", + "query": "window=flyout&fixture=copilot-daily-target&theme=dark", + "size": [310, 1032], + "steps": [ + { "expectText": ["Copilot", "Premium", "≈ 2.9% / day to last until reset"] }, + { "screenshot": "flyout-copilot-daily-target", "clip": "panel" } + ] + }, { "name": "settings-gemini-usage-source", "query": "window=settings&tab=providers:gemini&fixture=default&theme=dark", diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index dac2150551e..bf14a7db65a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -59,6 +59,12 @@ pub struct RateWindowSnapshot { pub reserve_eta_seconds: Option, #[serde(default)] pub pace: Option, + /// Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). + /// Set under the same pace gate as `pace`, but also when the lane has no + /// pace (an exhausted allowance). Absent outside monthly windows and when + /// usage or the reset is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daily_target: Option, /// Set while a longer exhausted pool (Kimi's monthly membership) blocks /// this window; presentation only, the raw percentages above stay as is. #[serde(default)] @@ -112,6 +118,7 @@ impl RateWindowSnapshot { reserve_will_last_to_reset: false, reserve_eta_seconds: None, pace: None, + daily_target: None, monthly_limit_block: None, description_is_detail: rw.description_is_detail, reset_regenerates: rw.reset_regenerates, @@ -606,6 +613,9 @@ impl ProviderUsageSnapshot { ) }) .flatten(), + daily_target: allows_pace + .then(|| pace::daily_target(Some(id), window, now)) + .flatten(), ..RateWindowSnapshot::from_rate_window(window).with_quota_block(is_blocked, &blocked) }; let primary_snap = lane( diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index 112ae6c23ae..7eed17e1500 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -36,10 +36,6 @@ pub struct WindowPaceSnapshot { /// pace on any card lane, whatever the window's length. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub reset_window: bool, - /// Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). - /// Absent outside monthly windows and when usage or the reset is unknown. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub daily_target: Option, } /// Remaining allowance divided by the local calendar days left, in the @@ -106,13 +102,7 @@ impl WindowPaceSnapshot { default_window_minutes, work_days, )?; - let mut snapshot = Self::from_pace(&pace, false); - snapshot.daily_target = daily_target( - &codexbar::core::ProviderPaceCapability::UNSUPPORTED, - window, - now, - ); - Some(snapshot) + Some(Self::from_pace(&pace, false)) } /// Lane pace with the provider's reset-window capability applied. @@ -129,9 +119,7 @@ impl WindowPaceSnapshot { } let (pace, reset_window) = lane_usage_pace(id, lane, window, default_window_minutes, now, work_days)?; - let mut snapshot = Self::from_pace(&pace, reset_window); - snapshot.daily_target = daily_target(&id.pace_capability(), window, now); - Some(snapshot) + Some(Self::from_pace(&pace, reset_window)) } fn from_pace(pace: &codexbar::core::UsagePace, reset_window: bool) -> Self { @@ -143,19 +131,23 @@ impl WindowPaceSnapshot { eta_seconds: pace.eta_seconds, will_last_to_reset: pace.will_last_to_reset, reset_window, - daily_target: None, } } } /// Daily target of a monthly window in local calendar days (the Mac uses /// `Calendar.current`). Upstream `monthlyTargetMetrics` reads the same -/// window, so the target never estimates today's consumption. -fn daily_target( - capability: &codexbar::core::ProviderPaceCapability, +/// window, so the target never estimates today's consumption. It does not +/// depend on a lane pace: an exhausted allowance has no pace but still has +/// a target. +pub(crate) fn daily_target( + id: Option, window: &RateWindow, now: DateTime, ) -> Option { + let capability = id.map_or(codexbar::core::ProviderPaceCapability::UNSUPPORTED, |id| { + id.pace_capability() + }); capability .monthly_daily_target(window, now, &chrono::Local) .map(DailyTargetSnapshot::from) @@ -211,7 +203,6 @@ mod tests { eta_seconds: None, will_last_to_reset: true, reset_window: false, - daily_target: None, }) ); } @@ -598,39 +589,44 @@ mod tests { #[test] fn monthly_credit_allowance_carries_a_daily_target_on_the_card_lane() { let window = monthly(20.0, Some(43_200)).with_quota(250.0, "credits"); - let pace = WindowPaceSnapshot::for_lane( - ProviderId::Notion, - PaceLane::Card, - &window, - WEEKLY_FALLBACK_MINUTES, - february_now(), - None, - ) - .expect("pace"); - let target = pace.daily_target.expect("daily target"); + let snap = snapshot_at(ProviderId::Notion, UsageSnapshot::new(window)); + + let target = snap.primary.daily_target.expect("daily target"); assert_eq!(target.unit, "credits"); let days = 200.0 / target.amount; assert!((13.0..=15.0).contains(&days), "{days}"); } + /// An exhausted allowance has no lane pace, but the card still shows its + /// target, as upstream computes the target apart from pace. + #[test] + fn exhausted_monthly_allowance_keeps_a_zero_daily_target_without_pace() { + let window = monthly(100.0, Some(43_200)).with_quota(250.0, "credits"); + + let snap = snapshot_at(ProviderId::Notion, UsageSnapshot::new(window)); + + assert_eq!(snap.primary.pace, None); + assert_eq!( + snap.primary.daily_target, + Some(DailyTargetSnapshot { + amount: 0.0, + unit: "credits".to_string(), + }) + ); + } + #[test] fn weekly_window_has_no_daily_target() { // Reset five days out, inside the seven-day window. let reset = Utc.with_ymd_and_hms(2026, 2, 20, 0, 0, 0).unwrap(); let window = RateWindow::with_details(20.0, Some(10_080), Some(reset), None) .with_quota(250.0, "credits"); - let pace = WindowPaceSnapshot::for_lane( - ProviderId::Notion, - PaceLane::Card, - &window, - WEEKLY_FALLBACK_MINUTES, - february_now(), - None, - ) - .expect("pace"); - assert_eq!(pace.daily_target, None); + let snap = snapshot_at(ProviderId::Notion, UsageSnapshot::new(window)); + + assert!(snap.primary.pace.is_some()); + assert_eq!(snap.primary.daily_target, None); } /// A provider that opts out of pace gets no daily target either, so the @@ -652,6 +648,7 @@ mod tests { ); assert_eq!(snap.primary.pace, None); + assert_eq!(snap.primary.daily_target, None); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 03d508ac218..1e525a8ede9 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -627,7 +627,7 @@ fn fill_window_pace( now: chrono::DateTime, work_days: Option, ) { - if w.pace.is_some() || w.is_informational { + if (w.pace.is_some() && w.daily_target.is_some()) || w.is_informational { return; } let Some(resets_at) = w @@ -643,6 +643,12 @@ fn fill_window_pace( Some(resets_at.with_timezone(&chrono::Utc)), w.reset_description.clone(), ); + if w.daily_target.is_none() { + w.daily_target = crate::commands::pace::daily_target(id, &window, now); + } + if w.pace.is_some() { + return; + } w.pace = match id { Some(id) => crate::commands::pace::WindowPaceSnapshot::for_lane( id, diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts index 6bcf37bd11b..2efb0e291a0 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.test.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -513,15 +513,23 @@ describe("metricRowPresentation bar", () => { describe("monthly daily target", () => { // A calendar-month allowance on the secondary lane with a synthetic target. - function monthly(dailyTarget: WindowPaceSnapshot["dailyTarget"]): RateWindowSnapshot { + function monthly(dailyTarget: RateWindowSnapshot["dailyTarget"]): RateWindowSnapshot { return rateWindow({ usedPercent: 79, remainingPercent: 21, windowMinutes: 43_200, - pace: pace({ dailyTarget }), + pace: pace(), + dailyTarget, }); } + it("shows the per-day share when the window has no pace", () => { + const snap = { ...monthly({ amount: 0, unit: "%" }), pace: null }; + expect(metricRowPresentation(input({ lane: "secondary", snap }), en).metaText).toContain( + "≈ 0% / day to last until reset", + ); + }); + it("shows a credit allowance's per-day share in the allowance's unit", () => { const row = metricRowPresentation( input({ lane: "secondary", snap: monthly({ amount: 21.0909, unit: "credits" }) }), @@ -531,7 +539,7 @@ describe("monthly daily target", () => { }); it("formats percent, request and unknown units", () => { - const text = (dailyTarget: WindowPaceSnapshot["dailyTarget"]) => + const text = (dailyTarget: RateWindowSnapshot["dailyTarget"]) => metricRowPresentation(input({ lane: "secondary", snap: monthly(dailyTarget) }), en).metaText ?? ""; expect(text({ amount: 2.46, unit: "%" })).toContain("≈ 2.5% / day to last until reset"); diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 36333601f35..f5a306085de 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -365,8 +365,8 @@ export function metricRowPresentation(input: MetricRowInput, t: Translate): Metr const markers = barMarkers(input); // The daily target shares the pace setting and the non-compact meta line. const dailyTarget = - input.paceEnabled && !input.compact && snap.pace?.dailyTarget - ? dailyTargetText(snap.pace.dailyTarget, t) + input.paceEnabled && !input.compact && snap.dailyTarget + ? dailyTargetText(snap.dailyTarget, t) : null; const metaParts = [ visible && !input.compact ? paceMetaText(visible.pace, visible.kind, t) : null, diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 0c5dc71977c..5edb4afec69 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -57,6 +57,7 @@ export const PROOF_SCENARIO_NAMES = [ "vercel-team-id-invalid", "kimi-bonus-credits", "agent-sessions-hosts", + "copilot-daily-target", ] as const; export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; @@ -1152,6 +1153,22 @@ function kimiBonusCreditsScenario(now: Date): ProofScenario { }; } +/** A monthly allowance with a daily target and no pace: 52% left over 18 days. */ +function copilotDailyTargetScenario(now: Date): ProofScenario { + const snapshot = copilotSnapshot(now); + return { + name: "copilot-daily-target", + settings: baseSettings({ enabledProviders: ["copilot"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [ + { ...snapshot, primary: { ...snapshot.primary, pace: null, dailyTarget: { amount: 52 / 18, unit: "%" } } }, + ], + codexAccounts: emptyCodexAccounts(), + claudeAccounts: [], + localSpend: [], + }; +} + function proofAgentSession( id: string, host: string, @@ -1230,6 +1247,7 @@ const BUILDERS: Record ProofScenario> = { "vercel-team-id-invalid": vercelTeamIdInvalidScenario, "kimi-bonus-credits": kimiBonusCreditsScenario, "agent-sessions-hosts": agentSessionsHostsScenario, + "copilot-daily-target": copilotDailyTargetScenario, }; export function isProofScenarioName(value: string): value is ProofScenarioName { diff --git a/apps/desktop-tauri/src/types/bridge/usage.ts b/apps/desktop-tauri/src/types/bridge/usage.ts index 058a024da79..05286e95deb 100644 --- a/apps/desktop-tauri/src/types/bridge/usage.ts +++ b/apps/desktop-tauri/src/types/bridge/usage.ts @@ -31,6 +31,12 @@ export interface RateWindowSnapshot { reserveEtaSeconds?: number | null; /** Pace of this window; absent when the provider's pace is not authoritative or the window is informational. */ pace?: WindowPaceSnapshot | null; + /** + * Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). + * Independent of pace scoring; omitted outside monthly windows and when + * usage or the reset is unknown. + */ + dailyTarget?: DailyTargetSnapshot | null; /** Set while a longer exhausted pool blocks this window; raw percentages stay the provider data. */ monthlyLimitBlock?: MonthlyLimitBlock | null; } @@ -89,11 +95,6 @@ export interface WindowPaceSnapshot { * pace whatever the window's length. Omitted when false. */ resetWindow?: boolean; - /** - * Per-day share of a monthly allowance (upstream `monthlyTargetMetrics`). - * Omitted outside monthly windows and when usage or the reset is unknown. - */ - dailyTarget?: DailyTargetSnapshot | null; } /** Remaining allowance over the local days left, in `unit` (`%`, `credits` or `requests`). */ diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 2e22597ae32..ea9f3a6644d 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -241,7 +241,7 @@ PaceOnPace = Beklenen tempoda PaceInReserve = %{ "{}" } rezervde PaceInDeficit = %{ "{}" } açıkta PaceLastsUntilReset = Sıfırlamaya kadar sürer -PaceDailyTargetPercent = ≈ günde { "{}" }%, sıfırlamaya kadar yeter +PaceDailyTargetPercent = ≈ günde %{ "{}" }, sıfırlamaya kadar yeter PaceDailyTargetCredits = ≈ günde { "{}" } kredi, sıfırlamaya kadar yeter PaceDailyTargetRequests = ≈ günde { "{}" } istek, sıfırlamaya kadar yeter PaceRunsOutIn = { "{}" } içinde biter diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index ea68d58fb02..22b1ea16573 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -210,7 +210,7 @@ PaceInReserve = 余量 { "{}" }% PaceInDeficit = 超额 { "{}" }% PaceLastsUntilReset = 持续到重置 PaceDailyTargetPercent = 每天约 { "{}" }%,可维持到重置 -PaceDailyTargetCredits = 每天约 { "{}" } 点数,可维持到重置 +PaceDailyTargetCredits = 每天约 { "{}" } 额度,可维持到重置 PaceDailyTargetRequests = 每天约 { "{}" } 次请求,可维持到重置 PaceRunsOutIn = 预计 { "{}" } 后耗尽 PaceRunsOutNow = 即将耗尽 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index c5bd29dc037..8900281df92 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -210,7 +210,7 @@ PaceInReserve = 比目前進度少用 { "{}" }% PaceInDeficit = 比目前進度多用 { "{}" }% PaceLastsUntilReset = 持續到重置 PaceDailyTargetPercent = 每天約 { "{}" }%,可維持到重置 -PaceDailyTargetCredits = 每天約 { "{}" } 點數,可維持到重置 +PaceDailyTargetCredits = 每天約 { "{}" } 額度,可維持到重置 PaceDailyTargetRequests = 每天約 { "{}" } 次請求,可維持到重置 PaceRunsOutIn = { "{}" } 後用完 PaceRunsOutNow = 現在用完 From 2d13a4c4859787f3efa9f8cdfa1190ec7b425449 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 03:01:10 +0700 Subject: [PATCH 3/5] Keep regenerating resets in seeded proof windows --- .../src-tauri/src/proof_harness.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 1e525a8ede9..8b25b09da09 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -637,12 +637,13 @@ fn fill_window_pace( else { return; }; - let window = codexbar::core::RateWindow::with_details( + let mut window = codexbar::core::RateWindow::with_details( w.used_percent, w.window_minutes, Some(resets_at.with_timezone(&chrono::Utc)), w.reset_description.clone(), ); + window.reset_regenerates = w.reset_regenerates; if w.daily_target.is_none() { w.daily_target = crate::commands::pace::daily_target(id, &window, now); } @@ -985,6 +986,25 @@ mod tests { assert!(snapshot.extra_rate_windows[0].window.pace.is_none()); } + #[test] + fn seed_daily_target_skips_regenerating_windows() { + use chrono::TimeZone; + let now = chrono::Utc.with_ymd_and_hms(2026, 2, 15, 0, 0, 0).unwrap(); + let seed = |regenerates: bool| { + let json = format!( + r#"{{"providerId":"copilot","displayName":"Copilot", + "primary":{{"usedPercent":60.0,"resetsAt":"2026-03-01T00:00:00Z", + "resetRegenerates":{regenerates}}}}}"# + ); + let mut snapshot: ProviderUsageSnapshot = serde_json::from_str(&json).unwrap(); + normalize_seed_snapshot(&mut snapshot); + fill_seed_pace(&mut snapshot, now, None); + snapshot.primary.daily_target + }; + assert!(seed(false).is_some()); + assert_eq!(seed(true), None); + } + #[test] fn seed_pace_keeps_explicit_pace() { let json = r#"{"providerId":"codex","displayName":"Codex", From 98971be741c7aeb52398c4dbb956686ba377a73d Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 03:28:00 +0700 Subject: [PATCH 4/5] Give over-quota monthly allowances a zero daily target --- rust/src/core/pace_capability.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/rust/src/core/pace_capability.rs b/rust/src/core/pace_capability.rs index d536d90d00a..9b00b696b17 100644 --- a/rust/src/core/pace_capability.rs +++ b/rust/src/core/pace_capability.rs @@ -203,7 +203,9 @@ impl ProviderPaceCapability { } _ => return None, }; - let remaining = limit * window.remaining_percent() / 100.0; + // Upstream `RateWindow.remainingPercent` floors at zero, so an + // over-quota allowance still gets a zero target. + let remaining = limit * window.remaining_percent().max(0.0) / 100.0; let amount = UsagePace::daily_target_in(remaining, window.resets_at, now, tz)?; Some(MonthlyDailyTarget { amount, @@ -265,6 +267,18 @@ mod tests { assert!((target.amount - 3.0).abs() < 1e-9); } + #[test] + fn over_quota_monthly_window_targets_zero() { + for used in [100.0, 125.0] { + let target = daily_target_of( + &monthly_reset(used).with_quota(300.0, "requests"), + ProviderPaceCapability::CALENDAR_MONTH_RESET_WINDOW, + ) + .expect("exhausted allowance keeps a target"); + assert_eq!(target.amount, 0.0, "used {used}%"); + } + } + #[test] fn credit_and_request_allowances_keep_their_unit() { let credits = monthly_reset(40.0).with_quota(250.0, "credits"); From ff116538f28186621f06b6856b2b28ebfd218e6a Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 03:41:43 +0700 Subject: [PATCH 5/5] Retrigger CI after runner out-of-memory