1pub mod dates;
2pub use dates::parse_iso_utc;
3
4pub fn plan_name(plan_id: &str) -> &'static str {
6 let id = plan_id.to_lowercase();
7 if id.contains("enterprise") {
8 "Enterprise"
9 } else if id.contains("provider") {
10 "Provider"
11 } else if id.contains("team") {
12 "Team Pro"
13 } else if id.contains("max") {
14 if id.contains("20") { "Max 20x" } else { "Max 10x" }
15 } else if id.contains("goat") {
16 "GOAT"
17 } else if id.contains("pro") {
18 "Pro"
19 } else if id.contains("go") {
20 "Go"
21 } else {
22 "Free"
23 }
24}
25
26pub fn plan_monthly_cap(plan_id: &str) -> Option<f64> {
29 let id = plan_id.to_lowercase();
30 if id.contains("enterprise") || id.contains("provider") {
31 None
32 } else if id.contains("team") {
33 Some(40.0)
34 } else if id.contains("max") {
35 if id.contains("20") { Some(300.0) } else { Some(150.0) }
36 } else if id.contains("goat") {
37 Some(70.0)
38 } else if id.contains("pro") {
39 Some(80.0)
40 } else if id.contains("go") {
41 Some(10.0)
42 } else {
43 None
44 }
45}
46
47pub fn money(v: f64) -> String {
48 format!("${v:.2}")
49}
50
51pub fn compact(n: u64) -> String {
52 if n >= 1_000_000 {
53 format!("{:.1}M", n as f64 / 1_000_000.0)
54 } else if n >= 1_000 {
55 format!("{:.1}K", n as f64 / 1_000.0)
56 } else {
57 format!("{n}")
58 }
59}
60
61pub fn pct(used: f64, cap: f64) -> String {
63 if cap > 0.0 {
64 format!("{:.0}%", (used / cap) * 100.0)
65 } else {
66 "—".into()
67 }
68}
69
70pub fn rel_time(reset_at: Option<f64>, now: Option<u64>) -> String {
73 let Some(reset_ms) = reset_at else {
74 return "unknown".into();
75 };
76 let Some(now_s) = now else {
77 return format!("epoch {}", reset_ms as u64 / 1000);
78 };
79 let reset_s = reset_ms as u64 / 1000;
80 if reset_s <= now_s {
81 return "resetting…".into();
84 }
85 let diff = reset_s - now_s;
86 let d = diff / 86400;
87 let h = (diff % 86400) / 3600;
88 let m = (diff % 3600) / 60;
89 if d > 0 {
90 format!("{d}d {h}h")
91 } else if h > 0 {
92 format!("{h}h {m}m")
93 } else if m > 0 {
94 format!("{m}m")
95 } else {
96 "<1m".into()
97 }
98}
99
100pub fn elapsed_pct(reset_at: Option<f64>, dur_secs: u64, now: Option<u64>) -> Option<u8> {
102 let reset_ms = reset_at?;
103 let now_s = now?;
104 let reset_s = reset_ms as u64 / 1000;
105 let start = reset_s.checked_sub(dur_secs)?;
106 if now_s < start {
107 return None; }
109 let elapsed = now_s - start;
110 let pct = (elapsed as f64 / dur_secs as f64 * 100.0).clamp(0.0, 100.0);
111 Some(pct.round() as u8)
112}
113
114pub fn pace_eta(
118 reset_at: Option<f64>,
119 dur_secs: u64,
120 used: f64,
121 cap: f64,
122 now: u64,
123) -> Option<f64> {
124 let reset_ms = reset_at?;
125 let reset_s = reset_ms as u64 / 1000;
126 let start = reset_s.checked_sub(dur_secs)?;
127 if now <= start || now >= reset_s {
128 return None; }
130 let elapsed = (now - start) as f64;
131 if elapsed / (dur_secs as f64) < 0.10 {
132 return None; }
134 let rate = used / elapsed; if rate <= 0.0 {
136 return None;
137 }
138 let secs_to_cap = (cap - used) / rate;
139 if secs_to_cap >= (reset_s - now) as f64 {
140 return None; }
142 Some(secs_to_cap)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn plan_names() {
151 assert_eq!(plan_name("individual-goat"), "GOAT");
152 assert_eq!(plan_name("individual-max-20"), "Max 20x");
153 assert_eq!(plan_name("individual-max-10"), "Max 10x");
154 assert_eq!(plan_name("individual-go"), "Go");
155 assert_eq!(plan_name("teams-pro"), "Team Pro");
156 assert_eq!(plan_name("individual-provider"), "Provider");
157 assert_eq!(plan_name("bogus"), "Free");
158 }
159
160 #[test]
161 fn monthly_caps() {
162 assert_eq!(plan_monthly_cap("individual-goat"), Some(70.0));
163 assert_eq!(plan_monthly_cap("individual-go"), Some(10.0));
164 assert_eq!(plan_monthly_cap("individual-pro"), Some(80.0));
165 assert_eq!(plan_monthly_cap("individual-max-10"), Some(150.0));
166 assert_eq!(plan_monthly_cap("individual-max-20"), Some(300.0));
167 assert_eq!(plan_monthly_cap("teams-pro"), Some(40.0));
168 assert_eq!(plan_monthly_cap("individual-provider"), None);
169 }
170
171 #[test]
172 fn iso_hour_start_roundtrips() {
173 use crate::dates::iso_hour_start;
174 for epoch in [1_700_000_000u64, 1_767_225_600, 1_000_000_000] {
175 let s = iso_hour_start(epoch);
176 assert!(s.contains('T'), "must be a parseable ISO string: {s}");
177 let ms = parse_iso_utc(&s).unwrap() as u64;
178 assert_eq!(ms, (epoch - epoch % 3600) * 1000);
179 }
180 }
181
182 #[test]
183 fn money_and_compact() {
184 assert_eq!(money(0.0), "$0.00");
185 assert_eq!(money(45.689), "$45.69");
186 assert_eq!(compact(999), "999");
187 assert_eq!(compact(2_300), "2.3K");
188 assert_eq!(compact(316_700_000), "316.7M");
189 }
190
191 #[test]
192 fn rel_time_human() {
193 assert_eq!(rel_time(None, Some(0)), "unknown");
194 assert_eq!(rel_time(Some(59_000.0), Some(0)), "<1m");
195 assert_eq!(rel_time(Some(120_000.0), Some(60)), "1m");
196 assert_eq!(rel_time(Some(7_200_000.0), Some(0)), "2h 0m");
197 assert_eq!(rel_time(Some(136_800_000.0), Some(0)), "1d 14h");
198 assert_eq!(rel_time(Some(1_000.0), Some(100)), "resetting…");
200 assert_eq!(rel_time(Some(1_000.0), None), "epoch 1");
202 }
203
204 #[test]
205 fn elapsed_pct_windows() {
206 let now = 1_000_000u64;
207 let reset = (now as f64 + 2.5 * 3600.0) * 1000.0;
208 assert_eq!(elapsed_pct(Some(reset), 5 * 3600, Some(now)), Some(50));
209 assert_eq!(elapsed_pct(Some(1.0), 3600, Some(now)), None);
210 assert_eq!(elapsed_pct(None, 3600, Some(now)), None);
211 }
212
213 #[test]
214 fn pace_warns_only_after_10pct_elapsed() {
215 let now = 1_000_000u64;
216 let d = 5 * 3600;
217 let start5 = now - d / 20;
219 let early = pace_eta(Some((start5 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
220 assert!(early.is_none(), "must not warn at 5% elapsed: {early:?}");
221 let start10 = now - d / 10;
223 let at10 = pace_eta(Some((start10 + d) as f64 * 1000.0), d, 5.0, 10.0, now);
224 assert_eq!(at10, Some(1800.0));
225 let start50 = now - d / 2;
227 let fine = pace_eta(Some((start50 + d) as f64 * 1000.0), d, 1.0, 10.0, now);
228 assert!(fine.is_none());
229 }
230}