1use std::collections::HashMap;
2
3use chrono::Datelike;
4use serde::Serialize;
5
6use crate::analysis::project::project_display_name;
7use crate::analysis::wrapped::WrappedResult;
8use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
9use crate::data::models::SessionData;
10use crate::pricing::calculator::PricingCalculator;
11
12#[derive(Serialize)]
15struct OverviewJson {
16 total_sessions: usize,
17 total_turns: usize,
18 total_agent_turns: usize,
19 total_output_tokens: u64,
20 total_context_tokens: u64,
21 total_cost: f64,
22 avg_cache_hit_rate: f64,
23 output_ratio: f64,
25 cost_per_turn: f64,
26 tokens_per_output_turn: u64,
27 cache_savings: CacheSavingsJson,
29 subscription_value: Option<SubscriptionValueJson>,
31 cost_by_category: CostByCategoryJson,
33 models: Vec<ModelJson>,
35 top_tools: Vec<ToolJson>,
37 sessions: Vec<SessionSummaryJson>,
39}
40
41#[derive(Serialize)]
42struct CacheSavingsJson {
43 total_saved: f64,
44 savings_pct: f64,
45}
46
47#[derive(Serialize)]
48struct SubscriptionValueJson {
49 monthly_price: f64,
50 api_equivalent: f64,
51 value_multiplier: f64,
52}
53
54#[derive(Serialize)]
55struct CostByCategoryJson {
56 input_cost: f64,
57 output_cost: f64,
58 cache_write_cost: f64,
59 cache_read_cost: f64,
60}
61
62#[derive(Serialize)]
63struct ModelJson {
64 name: String,
65 output_tokens: u64,
66 turns: usize,
67 cost: f64,
68}
69
70#[derive(Serialize)]
71struct ToolJson {
72 name: String,
73 count: usize,
74}
75
76#[derive(Serialize)]
77struct SessionSummaryJson {
78 session_id: String,
79 project: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 first_timestamp: Option<String>,
82 duration_minutes: f64,
83 model: String,
84 turn_count: usize,
85 agent_turn_count: usize,
86 output_tokens: u64,
87 context_tokens: u64,
88 max_context: u64,
89 cache_hit_rate: f64,
90 cost: f64,
91 output_ratio: f64,
92 cost_per_turn: f64,
93}
94
95fn build_overview_json(overview: &OverviewResult) -> OverviewJson {
97 let mut models: Vec<(&String, &crate::analysis::AggregatedTokens)> =
98 overview.tokens_by_model.iter().collect();
99 models.sort_by(|a, b| {
100 let ca = overview.cost_by_model.get(a.0).unwrap_or(&0.0);
101 let cb = overview.cost_by_model.get(b.0).unwrap_or(&0.0);
102 cb.partial_cmp(ca).unwrap_or(std::cmp::Ordering::Equal)
103 });
104
105 let models_json: Vec<ModelJson> = models
106 .iter()
107 .map(|(name, tokens)| ModelJson {
108 name: (*name).clone(),
109 output_tokens: tokens.output_tokens,
110 turns: tokens.turns,
111 cost: *overview.cost_by_model.get(*name).unwrap_or(&0.0),
112 })
113 .collect();
114
115 let top_tools: Vec<ToolJson> = overview
116 .tool_counts
117 .iter()
118 .take(20)
119 .map(|(name, count)| ToolJson {
120 name: name.clone(),
121 count: *count,
122 })
123 .collect();
124
125 let sessions: Vec<SessionSummaryJson> = overview
126 .session_summaries
127 .iter()
128 .map(|s| SessionSummaryJson {
129 session_id: s.session_id.clone(),
130 project: s.project_display_name.clone(),
131 first_timestamp: s.first_timestamp.map(|t| t.to_rfc3339()),
132 duration_minutes: s.duration_minutes,
133 model: s.model.clone(),
134 turn_count: s.turn_count,
135 agent_turn_count: s.agent_turn_count,
136 output_tokens: s.output_tokens,
137 context_tokens: s.context_tokens,
138 max_context: s.max_context,
139 cache_hit_rate: s.cache_hit_rate,
140 cost: s.cost,
141 output_ratio: s.output_ratio,
142 cost_per_turn: s.cost_per_turn,
143 })
144 .collect();
145
146 let cat = &overview.cost_by_category;
147
148 OverviewJson {
149 total_sessions: overview.total_sessions,
150 total_turns: overview.total_turns,
151 total_agent_turns: overview.total_agent_turns,
152 total_output_tokens: overview.total_output_tokens,
153 total_context_tokens: overview.total_context_tokens,
154 total_cost: overview.total_cost,
155 avg_cache_hit_rate: overview.avg_cache_hit_rate,
156 output_ratio: overview.output_ratio,
157 cost_per_turn: overview.cost_per_turn,
158 tokens_per_output_turn: overview.tokens_per_output_turn,
159 cache_savings: CacheSavingsJson {
160 total_saved: overview.cache_savings.total_saved,
161 savings_pct: overview.cache_savings.savings_pct,
162 },
163 subscription_value: overview
164 .subscription_value
165 .as_ref()
166 .map(|sv| SubscriptionValueJson {
167 monthly_price: sv.monthly_price,
168 api_equivalent: sv.api_equivalent,
169 value_multiplier: sv.value_multiplier,
170 }),
171 cost_by_category: CostByCategoryJson {
172 input_cost: cat.input_cost,
173 output_cost: cat.output_cost,
174 cache_write_cost: cat.cache_write_5m_cost + cat.cache_write_1h_cost,
175 cache_read_cost: cat.cache_read_cost,
176 },
177 models: models_json,
178 top_tools,
179 sessions,
180 }
181}
182
183pub fn render_overview_json(overview: &OverviewResult) -> String {
184 let json = build_overview_json(overview);
185 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
186}
187
188#[derive(Serialize)]
191struct SessionJson {
192 session_id: String,
193 project: String,
194 model: String,
195 duration_minutes: f64,
196 total_cost: f64,
197 max_context: u64,
198 compaction_count: usize,
199 output_tokens: u64,
201 context_tokens: u64,
202 cache_hit_rate: f64,
203 agent_turns: usize,
205 agent_output_tokens: u64,
206 agent_cost: f64,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 title: Option<String>,
210 #[serde(skip_serializing_if = "Vec::is_empty")]
211 tags: Vec<String>,
212 turns: Vec<TurnJson>,
214}
215
216#[derive(Serialize)]
217struct TurnJson {
218 turn_number: usize,
219 timestamp: String,
220 model: String,
221 input_tokens: u64,
222 output_tokens: u64,
223 cache_read_tokens: u64,
224 context_size: u64,
225 cache_hit_rate: f64,
226 cost: f64,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 stop_reason: Option<String>,
229 is_agent: bool,
230 is_compaction: bool,
231 #[serde(skip_serializing_if = "Vec::is_empty")]
232 tool_names: Vec<String>,
233}
234
235pub fn render_session_json(result: &SessionResult) -> String {
236 let ctx = result.total_tokens.context_tokens();
237 let cache_hit_rate = if ctx > 0 {
238 result.total_tokens.cache_read_tokens as f64 / ctx as f64 * 100.0
239 } else {
240 0.0
241 };
242
243 let turns: Vec<TurnJson> = result
244 .turn_details
245 .iter()
246 .map(|t| TurnJson {
247 turn_number: t.turn_number,
248 timestamp: t.timestamp.to_rfc3339(),
249 model: t.model.clone(),
250 input_tokens: t.input_tokens,
251 output_tokens: t.output_tokens,
252 cache_read_tokens: t.cache_read_tokens,
253 context_size: t.context_size,
254 cache_hit_rate: t.cache_hit_rate,
255 cost: t.cost,
256 stop_reason: t.stop_reason.clone(),
257 is_agent: t.is_agent,
258 is_compaction: t.is_compaction,
259 tool_names: t.tool_names.clone(),
260 })
261 .collect();
262
263 let json = SessionJson {
264 session_id: result.session_id.clone(),
265 project: result.project.clone(),
266 model: result.model.clone(),
267 duration_minutes: result.duration_minutes,
268 total_cost: result.total_cost,
269 max_context: result.max_context,
270 compaction_count: result.compaction_count,
271 output_tokens: result.total_tokens.output_tokens,
272 context_tokens: ctx,
273 cache_hit_rate,
274 agent_turns: result.agent_summary.total_agent_turns,
275 agent_output_tokens: result.agent_summary.agent_output_tokens,
276 agent_cost: result.agent_summary.agent_cost,
277 title: result.title.clone(),
278 tags: result.tags.clone(),
279 turns,
280 };
281
282 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
283}
284
285#[derive(Serialize)]
288struct ProjectsJson {
289 projects: Vec<ProjectJson>,
290}
291
292#[derive(Serialize)]
293struct ProjectJson {
294 name: String,
295 display_name: String,
296 session_count: usize,
297 total_turns: usize,
298 agent_turns: usize,
299 output_tokens: u64,
300 context_tokens: u64,
301 cost: f64,
302 primary_model: String,
303}
304
305fn build_projects_json(projects: &ProjectResult) -> ProjectsJson {
307 ProjectsJson {
308 projects: projects
309 .projects
310 .iter()
311 .map(|p| ProjectJson {
312 name: p.name.clone(),
313 display_name: p.display_name.clone(),
314 session_count: p.session_count,
315 total_turns: p.total_turns,
316 agent_turns: p.agent_turns,
317 output_tokens: p.tokens.output_tokens,
318 context_tokens: p.tokens.context_tokens(),
319 cost: p.cost,
320 primary_model: p.primary_model.clone(),
321 })
322 .collect(),
323 }
324}
325
326pub fn render_projects_json(projects: &ProjectResult) -> String {
327 let json = build_projects_json(projects);
328 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
329}
330
331#[derive(Serialize)]
334struct TrendJson {
335 group_label: String,
336 entries: Vec<TrendEntryJson>,
337}
338
339#[derive(Serialize)]
340struct TrendEntryJson {
341 label: String,
342 session_count: usize,
343 turn_count: usize,
344 output_tokens: u64,
345 context_tokens: u64,
346 cost: f64,
347 cost_per_turn: f64,
348}
349
350fn build_trend_json(trend: &TrendResult) -> TrendJson {
352 TrendJson {
353 group_label: trend.group_label.clone(),
354 entries: trend
355 .entries
356 .iter()
357 .map(|e| {
358 let cpt = if e.turn_count > 0 {
359 e.cost / e.turn_count as f64
360 } else {
361 0.0
362 };
363 TrendEntryJson {
364 label: e.label.clone(),
365 session_count: e.session_count,
366 turn_count: e.turn_count,
367 output_tokens: e.tokens.output_tokens,
368 context_tokens: e.tokens.context_tokens(),
369 cost: e.cost,
370 cost_per_turn: cpt,
371 }
372 })
373 .collect(),
374 }
375}
376
377pub fn render_trend_json(trend: &TrendResult) -> String {
378 let json = build_trend_json(trend);
379 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
380}
381
382pub fn render_wrapped_json(result: &WrappedResult) -> String {
385 serde_json::to_string_pretty(result).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
386}
387
388#[derive(Serialize)]
393pub struct HtmlReportPayload {
394 pub overview: serde_json::Value,
395 pub projects: serde_json::Value,
396 pub trends: serde_json::Value,
397 pub sessions: Vec<HtmlSessionSummary>,
398 pub heatmap: HeatmapPayload,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub wrapped: Option<serde_json::Value>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 pub active_session_id: Option<String>,
403}
404
405#[derive(Serialize)]
407pub struct HtmlSessionSummary {
408 pub id: String,
409 pub project: Option<String>,
410 pub turns: usize,
411 pub agent_turns: usize,
412 pub cost: f64,
413 pub duration_minutes: Option<f64>,
414 pub model: Option<String>,
415 pub cache_hit_rate: Option<f64>,
416 #[serde(skip_serializing_if = "Option::is_none")]
417 pub first_timestamp: Option<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub last_timestamp: Option<String>,
420 pub title: Option<String>,
422 #[serde(skip_serializing_if = "Vec::is_empty")]
423 pub tags: Vec<String>,
424 pub mode: Option<String>,
425}
426
427#[derive(Serialize)]
429pub struct HeatmapPayload {
430 pub days: Vec<DailyActivity>,
431}
432
433#[derive(Serialize)]
435pub struct DailyActivity {
436 pub date: String,
437 pub turns: usize,
438 pub cost: f64,
439 pub sessions: usize,
440}
441
442pub fn render_html_payload(
447 overview: &OverviewResult,
448 projects: &ProjectResult,
449 trend: &TrendResult,
450 sessions: &[SessionData],
451 calc: &PricingCalculator,
452 wrapped: Option<&WrappedResult>,
453 active_session_id: Option<&str>,
454) -> String {
455 let overview_json: serde_json::Value =
457 serde_json::to_value(build_overview_json(overview)).unwrap_or(serde_json::Value::Null);
458 let projects_json: serde_json::Value =
459 serde_json::to_value(build_projects_json(projects)).unwrap_or(serde_json::Value::Null);
460 let trends_json: serde_json::Value =
461 serde_json::to_value(build_trend_json(trend)).unwrap_or(serde_json::Value::Null);
462
463 let session_summaries: Vec<HtmlSessionSummary> = sessions
465 .iter()
466 .map(|s| build_html_session_summary(s, calc))
467 .collect();
468
469 let heatmap = build_heatmap(sessions, calc);
471
472 let wrapped_json: Option<serde_json::Value> =
474 wrapped.and_then(|w| serde_json::to_value(w).ok());
475
476 let payload = HtmlReportPayload {
477 overview: overview_json,
478 projects: projects_json,
479 trends: trends_json,
480 sessions: session_summaries,
481 heatmap,
482 wrapped: wrapped_json,
483 active_session_id: active_session_id.map(|s| s.to_string()),
484 };
485
486 serde_json::to_string(&payload).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
487}
488
489fn build_html_session_summary(
491 session: &SessionData,
492 calc: &PricingCalculator,
493) -> HtmlSessionSummary {
494 let all = session.all_responses();
495 let turn_count = all.len();
496 let agent_turn_count = session.agent_turn_count();
497
498 let mut total_cost = 0.0;
500 let mut total_cache_read: u64 = 0;
501 let mut total_context: u64 = 0;
502 let mut model_counts: HashMap<&str, usize> = HashMap::new();
503
504 for turn in &all {
505 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
506 total_cost += cost.total;
507
508 let input = turn.usage.input_tokens.unwrap_or(0);
509 let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
510 let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
511 let ctx = input + cache_create + cache_read;
512
513 total_context += ctx;
514 total_cache_read += cache_read;
515
516 *model_counts.entry(&turn.model).or_insert(0) += 1;
517 }
518
519 let cache_hit_rate = if total_context > 0 {
520 Some((total_cache_read as f64 / total_context as f64) * 100.0)
521 } else {
522 None
523 };
524
525 let primary_model = model_counts
526 .into_iter()
527 .max_by_key(|(_, count)| *count)
528 .map(|(m, _)| m.to_string());
529
530 let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
531 (Some(first), Some(last)) => Some((last - first).num_seconds() as f64 / 60.0),
532 _ => None,
533 };
534
535 HtmlSessionSummary {
536 id: session.session_id.clone(),
537 project: session.project.as_deref().map(project_display_name),
538 turns: turn_count,
539 agent_turns: agent_turn_count,
540 cost: total_cost,
541 duration_minutes,
542 model: primary_model,
543 cache_hit_rate,
544 first_timestamp: session.first_timestamp.map(|t| t.to_rfc3339()),
545 last_timestamp: session.last_timestamp.map(|t| t.to_rfc3339()),
546 title: session.metadata.title.clone(),
547 tags: session.metadata.tags.clone(),
548 mode: session.metadata.mode.clone(),
549 }
550}
551
552fn build_heatmap(sessions: &[SessionData], calc: &PricingCalculator) -> HeatmapPayload {
554 let mut daily_map: HashMap<String, (usize, f64, usize)> = HashMap::new(); for session in sessions {
557 let date_key = match session.first_timestamp {
559 Some(ts) => {
560 let local = ts.with_timezone(&chrono::Local);
561 format!(
562 "{:04}-{:02}-{:02}",
563 local.year(),
564 local.month(),
565 local.day()
566 )
567 }
568 None => continue,
569 };
570
571 let all = session.all_responses();
572 let turn_count = all.len();
573 let mut session_cost = 0.0;
574 for turn in &all {
575 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
576 session_cost += cost.total;
577 }
578
579 let entry = daily_map.entry(date_key).or_insert((0, 0.0, 0));
580 entry.0 += turn_count;
581 entry.1 += session_cost;
582 entry.2 += 1;
583 }
584
585 let mut days: Vec<DailyActivity> = daily_map
586 .into_iter()
587 .map(|(date, (turns, cost, session_count))| DailyActivity {
588 date,
589 turns,
590 cost,
591 sessions: session_count,
592 })
593 .collect();
594
595 days.sort_by(|a, b| a.date.cmp(&b.date));
597
598 HeatmapPayload { days }
599}