1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum DataSource {
7 OauthApi,
8 WebApi,
9 CliProbe,
10}
11
12#[derive(Clone, Copy, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum UsageLevel {
15 Normal,
16 Warn,
17 Danger,
18 Over,
19}
20
21#[derive(Serialize)]
22#[serde(tag = "status", rename_all = "snake_case")]
23pub enum WidgetPayload {
24 Active { data: Box<ActivePayload> },
25 Error { title: String, message: String },
26}
27
28#[derive(Serialize)]
29pub struct ActivePayload {
30 pub five_hour: Option<UsageWindow>,
31 pub seven_day: Option<UsageWindow>,
32 pub seven_day_sonnet: Option<UsageWindow>,
33 pub seven_day_opus: Option<UsageWindow>,
34 pub extra_usage: Option<ExtraUsage>,
35 pub updated_at: String,
36 pub source: DataSource,
37 pub cost_data: Option<CostData>,
38}
39
40#[derive(Serialize, Clone)]
41pub struct UsageWindow {
42 pub utilization: f64,
43 pub resets_at: Option<String>,
44 pub resets_in_minutes: Option<f64>,
45 pub usage_level: UsageLevel,
46 pub pace: Option<PaceInfo>,
47}
48
49#[derive(Serialize, Clone)]
50pub struct PaceInfo {
51 pub delta_percent: f64,
52 pub expected_percent: f64,
53 pub will_last_to_reset: bool,
54 pub eta_minutes: Option<f64>,
55}
56
57#[derive(Serialize, Clone)]
58pub struct ExtraUsage {
59 pub is_enabled: bool,
60 pub monthly_limit: Option<f64>,
61 pub used_credits: Option<f64>,
62 pub utilization: Option<f64>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct CostData {
67 pub last_30d_cost_usd: Option<f64>,
68 pub models: Vec<String>,
69}
70
71#[derive(Debug, Deserialize, Serialize, Clone)]
72pub struct ApiResponse {
73 pub five_hour: Option<ApiWindow>,
74 pub seven_day: Option<ApiWindow>,
75 pub seven_day_sonnet: Option<ApiWindow>,
76 pub seven_day_opus: Option<ApiWindow>,
77 pub extra_usage: Option<ApiExtraUsage>,
78}
79
80#[derive(Debug, Deserialize, Serialize)]
81pub struct CachedResponse {
82 pub cached_at: String,
83 pub response: ApiResponse,
84 pub source: DataSource,
85 pub cost_data: Option<CostData>,
86}
87
88#[derive(Debug, Deserialize, Serialize, Clone)]
89pub struct ApiWindow {
90 pub utilization: Option<f64>,
91 pub resets_at: Option<String>,
92}
93
94#[derive(Debug, Deserialize, Serialize, Clone)]
95pub struct ApiExtraUsage {
96 pub is_enabled: bool,
97 pub monthly_limit: Option<f64>,
98 pub used_credits: Option<f64>,
99 pub utilization: Option<f64>,
100}
101
102#[derive(Debug, Deserialize)]
103pub struct KeychainPayload {
104 #[serde(rename = "claudeAiOauth")]
105 pub claude_ai_oauth: Option<OAuthData>,
106}
107
108#[derive(Debug, Deserialize)]
109pub struct OAuthData {
110 #[serde(rename = "accessToken")]
111 pub access_token: Option<String>,
112 #[serde(rename = "expiresAt")]
113 pub expires_at: Option<i64>,
114}
115
116#[derive(Debug, Deserialize)]
117pub struct CredentialsFile {
118 #[serde(rename = "claudeAiOauth")]
119 pub claude_ai_oauth: Option<OAuthData>,
120}
121
122pub fn parse_reset_time(s: &str) -> Option<DateTime<Utc>> {
123 DateTime::parse_from_rfc3339(s)
124 .ok()
125 .map(|dt| dt.with_timezone(&Utc))
126}
127
128pub fn usage_level(percent: f64) -> UsageLevel {
129 if percent >= 100.0 {
130 UsageLevel::Over
131 } else if percent >= 80.0 {
132 UsageLevel::Danger
133 } else if percent >= 60.0 {
134 UsageLevel::Warn
135 } else {
136 UsageLevel::Normal
137 }
138}
139
140fn compute_pace(utilization: f64, resets_in_minutes: f64, window_minutes: f64) -> Option<PaceInfo> {
141 if window_minutes <= 0.0 || resets_in_minutes <= 0.0 || resets_in_minutes > window_minutes {
142 return None;
143 }
144
145 let elapsed_secs = (window_minutes - resets_in_minutes) * 60.0;
146 let duration_secs = window_minutes * 60.0;
147 let time_until_reset_secs = resets_in_minutes * 60.0;
148
149 let actual = utilization.clamp(0.0, 100.0);
150 let expected = ((elapsed_secs / duration_secs) * 100.0).clamp(0.0, 100.0);
151
152 if elapsed_secs == 0.0 && actual > 0.0 {
154 return None;
155 }
156 if expected < 3.0 {
157 return None;
158 }
159
160 let delta = actual - expected;
161
162 let (will_last_to_reset, eta_minutes) = if elapsed_secs > 0.0 && actual > 0.0 {
163 let rate = actual / elapsed_secs;
164 if rate > 0.0 {
165 let remaining = (100.0 - actual).max(0.0);
166 let candidate_secs = remaining / rate;
167 if candidate_secs >= time_until_reset_secs {
168 (true, None)
169 } else {
170 (false, Some(candidate_secs / 60.0))
171 }
172 } else {
173 (true, None)
174 }
175 } else if elapsed_secs > 0.0 {
176 (true, None)
177 } else {
178 return None;
179 };
180
181 Some(PaceInfo {
182 delta_percent: delta,
183 expected_percent: expected,
184 will_last_to_reset,
185 eta_minutes,
186 })
187}
188
189pub fn to_usage_window(
190 api: &ApiWindow,
191 now: DateTime<Utc>,
192 window_minutes: Option<f64>,
193) -> UsageWindow {
194 let utilization = api.utilization.unwrap_or(0.0);
195 let reset_dt = api.resets_at.as_deref().and_then(parse_reset_time);
196 let resets_in_minutes = reset_dt.map(|reset| {
197 let delta = reset.signed_duration_since(now);
198 (delta.num_seconds() as f64 / 60.0).max(0.0)
199 });
200
201 let pace = match (window_minutes, resets_in_minutes) {
202 (Some(wm), Some(rm)) => compute_pace(utilization, rm, wm),
203 _ => None,
204 };
205
206 UsageWindow {
207 utilization,
208 resets_at: api.resets_at.clone(),
209 resets_in_minutes,
210 usage_level: usage_level(utilization),
211 pace,
212 }
213}