1use std::collections::HashMap;
2
3use chrono::Datelike;
4use serde::Serialize;
5
6use crate::analysis::heatmap::HeatmapResult;
7use crate::analysis::project::project_display_name;
8use crate::analysis::wrapped::WrappedResult;
9use crate::analysis::{OverviewResult, ProjectResult, SessionResult, TrendResult};
10use crate::data::models::{HookUsage, PluginUsage, SessionData, SkillUsage, SubagentTypeAggregate};
11use crate::pricing::calculator::PricingCalculator;
12
13#[derive(Serialize)]
16struct OverviewJson {
17 total_sessions: usize,
18 total_turns: usize,
19 total_agent_turns: usize,
20 total_output_tokens: u64,
21 total_context_tokens: u64,
22 total_cost: f64,
23 avg_cache_hit_rate: f64,
24 output_ratio: f64,
26 cost_per_turn: f64,
27 tokens_per_output_turn: u64,
28 cache_savings: CacheSavingsJson,
30 subscription_value: Option<SubscriptionValueJson>,
32 cost_by_category: CostByCategoryJson,
34 models: Vec<ModelJson>,
36 top_tools: Vec<ToolJson>,
38 sessions: Vec<SessionSummaryJson>,
40 pricing_warnings: Vec<PricingWarningJson>,
44}
45
46#[derive(Serialize)]
47struct PricingWarningJson {
48 unknown_model: String,
49 fallback_to: String,
50 turn_count: u64,
51 fallback_cost: f64,
52}
53
54#[derive(Serialize)]
55struct CacheSavingsJson {
56 total_saved: f64,
57 savings_pct: f64,
58}
59
60#[derive(Serialize)]
61struct SubscriptionValueJson {
62 monthly_price: f64,
63 api_equivalent: f64,
64 value_multiplier: f64,
65}
66
67#[derive(Serialize)]
68struct CostByCategoryJson {
69 input_cost: f64,
70 output_cost: f64,
71 cache_write_cost: f64,
72 cache_read_cost: f64,
73}
74
75#[derive(Serialize)]
76struct ModelJson {
77 name: String,
78 output_tokens: u64,
79 turns: usize,
80 cost: f64,
81}
82
83#[derive(Serialize)]
84struct ToolJson {
85 name: String,
86 count: usize,
87}
88
89#[derive(Serialize)]
90struct SessionSummaryJson {
91 session_id: String,
92 project: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 first_timestamp: Option<String>,
95 duration_minutes: f64,
96 model: String,
97 turn_count: usize,
98 #[serde(rename = "agentTurnCount")]
99 agent_turn_count: u64,
100 output_tokens: u64,
101 context_tokens: u64,
102 max_context: u64,
103 cache_hit_rate: f64,
104 cost: f64,
105 output_ratio: f64,
106 cost_per_turn: f64,
107 #[serde(rename = "isOrphan")]
108 is_orphan: bool,
109}
110
111fn build_overview_json(overview: &OverviewResult) -> OverviewJson {
113 let mut models: Vec<(&String, &crate::analysis::AggregatedTokens)> =
114 overview.tokens_by_model.iter().collect();
115 models.sort_by(|a, b| {
116 let ca = overview.cost_by_model.get(a.0).unwrap_or(&0.0);
117 let cb = overview.cost_by_model.get(b.0).unwrap_or(&0.0);
118 cb.partial_cmp(ca).unwrap_or(std::cmp::Ordering::Equal)
119 });
120
121 let models_json: Vec<ModelJson> = models
122 .iter()
123 .map(|(name, tokens)| ModelJson {
124 name: (*name).clone(),
125 output_tokens: tokens.output_tokens,
126 turns: tokens.turns,
127 cost: *overview.cost_by_model.get(*name).unwrap_or(&0.0),
128 })
129 .collect();
130
131 let top_tools: Vec<ToolJson> = overview
132 .tool_counts
133 .iter()
134 .take(20)
135 .map(|(name, count)| ToolJson {
136 name: name.clone(),
137 count: *count,
138 })
139 .collect();
140
141 let sessions: Vec<SessionSummaryJson> = overview
142 .session_summaries
143 .iter()
144 .map(|s| SessionSummaryJson {
145 session_id: s.session_id.clone(),
146 project: s.project_display_name.clone(),
147 first_timestamp: s.first_timestamp.map(|t| t.to_rfc3339()),
148 duration_minutes: s.duration_minutes,
149 model: s.model.clone(),
150 turn_count: s.turn_count,
151 agent_turn_count: s.agent_turn_count as u64,
152 output_tokens: s.output_tokens,
153 context_tokens: s.context_tokens,
154 max_context: s.max_context,
155 cache_hit_rate: s.cache_hit_rate,
156 cost: s.cost,
157 output_ratio: s.output_ratio,
158 cost_per_turn: s.cost_per_turn,
159 is_orphan: s.is_orphan,
160 })
161 .collect();
162
163 let cat = &overview.cost_by_category;
164
165 OverviewJson {
166 total_sessions: overview.total_sessions,
167 total_turns: overview.total_turns,
168 total_agent_turns: overview.total_agent_turns,
169 total_output_tokens: overview.total_output_tokens,
170 total_context_tokens: overview.total_context_tokens,
171 total_cost: overview.total_cost,
172 avg_cache_hit_rate: overview.avg_cache_hit_rate,
173 output_ratio: overview.output_ratio,
174 cost_per_turn: overview.cost_per_turn,
175 tokens_per_output_turn: overview.tokens_per_output_turn,
176 cache_savings: CacheSavingsJson {
177 total_saved: overview.cache_savings.total_saved,
178 savings_pct: overview.cache_savings.savings_pct,
179 },
180 subscription_value: overview
181 .subscription_value
182 .as_ref()
183 .map(|sv| SubscriptionValueJson {
184 monthly_price: sv.monthly_price,
185 api_equivalent: sv.api_equivalent,
186 value_multiplier: sv.value_multiplier,
187 }),
188 cost_by_category: CostByCategoryJson {
189 input_cost: cat.input_cost,
190 output_cost: cat.output_cost,
191 cache_write_cost: cat.cache_write_5m_cost + cat.cache_write_1h_cost,
192 cache_read_cost: cat.cache_read_cost,
193 },
194 models: models_json,
195 top_tools,
196 sessions,
197 pricing_warnings: overview
198 .pricing_warnings
199 .iter()
200 .map(|w| PricingWarningJson {
201 unknown_model: w.unknown_model.clone(),
202 fallback_to: w.fallback_to.clone(),
203 turn_count: w.turn_count,
204 fallback_cost: w.fallback_cost,
205 })
206 .collect(),
207 }
208}
209
210pub fn render_overview_json(overview: &OverviewResult) -> String {
211 let json = build_overview_json(overview);
212 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
213}
214
215#[derive(Serialize)]
218struct SessionJson {
219 session_id: String,
220 project: String,
221 model: String,
222 duration_minutes: f64,
223 total_cost: f64,
224 max_context: u64,
225 compaction_count: usize,
226 output_tokens: u64,
228 context_tokens: u64,
229 cache_hit_rate: f64,
230 #[serde(rename = "agentTurnCount")]
232 agent_turn_count: u64,
233 agent_output_tokens: u64,
234 agent_cost: f64,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 title: Option<String>,
238 #[serde(skip_serializing_if = "Vec::is_empty")]
239 tags: Vec<String>,
240 turns: Vec<TurnJson>,
242 subagents: Vec<SubagentJson>,
246 plugins: Vec<PluginUsage>,
247 skills: Vec<SkillUsage>,
248 hooks: Vec<HookUsage>,
249 #[serde(rename = "subagentTypes")]
252 subagent_types: Vec<SubagentTypeAggregate>,
253 #[serde(rename = "isOrphan")]
256 is_orphan: bool,
257}
258
259#[derive(Serialize)]
263#[serde(rename_all = "camelCase")]
264struct SubagentJson {
265 agent_id: String,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 agent_type: Option<String>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 description: Option<String>,
270 turns: usize,
271 output_tokens: u64,
272 cost: f64,
273}
274
275#[derive(Serialize)]
276struct TurnJson {
277 turn_number: usize,
278 timestamp: String,
279 model: String,
280 input_tokens: u64,
281 output_tokens: u64,
282 cache_read_tokens: u64,
283 context_size: u64,
284 cache_hit_rate: f64,
285 cost: f64,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 stop_reason: Option<String>,
288 is_agent: bool,
289 is_compaction: bool,
290 #[serde(skip_serializing_if = "Vec::is_empty")]
291 tool_names: Vec<String>,
292}
293
294pub fn render_session_json(result: &SessionResult) -> String {
295 let ctx = result.total_tokens.context_tokens();
296 let cache_hit_rate = if ctx > 0 {
297 result.total_tokens.cache_read_tokens as f64 / ctx as f64 * 100.0
298 } else {
299 0.0
300 };
301
302 let turns: Vec<TurnJson> = result
303 .turn_details
304 .iter()
305 .map(|t| TurnJson {
306 turn_number: t.turn_number,
307 timestamp: t.timestamp.to_rfc3339(),
308 model: t.model.clone(),
309 input_tokens: t.input_tokens,
310 output_tokens: t.output_tokens,
311 cache_read_tokens: t.cache_read_tokens,
312 context_size: t.context_size,
313 cache_hit_rate: t.cache_hit_rate,
314 cost: t.cost,
315 stop_reason: t.stop_reason.clone(),
316 is_agent: t.is_agent,
317 is_compaction: t.is_compaction,
318 tool_names: t.tool_names.clone(),
319 })
320 .collect();
321
322 let subagents: Vec<SubagentJson> = result
323 .subagents
324 .iter()
325 .map(|s| SubagentJson {
326 agent_id: s.agent_id.clone(),
327 agent_type: s.agent_type.clone(),
328 description: s.description.clone(),
329 turns: s.turns,
330 output_tokens: s.output_tokens,
331 cost: s.cost,
332 })
333 .collect();
334
335 let json = SessionJson {
336 session_id: result.session_id.clone(),
337 project: result.project.clone(),
338 model: result.model.clone(),
339 duration_minutes: result.duration_minutes,
340 total_cost: result.total_cost,
341 max_context: result.max_context,
342 compaction_count: result.compaction_count,
343 output_tokens: result.total_tokens.output_tokens,
344 context_tokens: ctx,
345 cache_hit_rate,
346 agent_turn_count: result.agent_summary.total_agent_turns as u64,
347 agent_output_tokens: result.agent_summary.agent_output_tokens,
348 agent_cost: result.agent_summary.agent_cost,
349 title: result.title.clone(),
350 tags: result.tags.clone(),
351 turns,
352 subagents,
353 plugins: result.plugins.clone(),
354 skills: result.skills.clone(),
355 hooks: result.hooks.clone(),
356 subagent_types: result.subagent_types.clone(),
357 is_orphan: result.is_orphan,
358 };
359
360 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
361}
362
363#[derive(Serialize)]
366struct ProjectsJson {
367 projects: Vec<ProjectJson>,
368}
369
370#[derive(Serialize)]
371struct ProjectJson {
372 name: String,
373 display_name: String,
374 session_count: usize,
375 total_turns: usize,
376 agent_turns: usize,
377 output_tokens: u64,
378 context_tokens: u64,
379 cost: f64,
380 primary_model: String,
381}
382
383fn build_projects_json(projects: &ProjectResult) -> ProjectsJson {
385 ProjectsJson {
386 projects: projects
387 .projects
388 .iter()
389 .map(|p| ProjectJson {
390 name: p.name.clone(),
391 display_name: p.display_name.clone(),
392 session_count: p.session_count,
393 total_turns: p.total_turns,
394 agent_turns: p.agent_turns,
395 output_tokens: p.tokens.output_tokens,
396 context_tokens: p.tokens.context_tokens(),
397 cost: p.cost,
398 primary_model: p.primary_model.clone(),
399 })
400 .collect(),
401 }
402}
403
404pub fn render_projects_json(projects: &ProjectResult) -> String {
405 let json = build_projects_json(projects);
406 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
407}
408
409#[derive(Serialize)]
412struct TrendJson {
413 group_label: String,
414 entries: Vec<TrendEntryJson>,
415}
416
417#[derive(Serialize)]
418struct TrendEntryJson {
419 label: String,
420 session_count: usize,
421 turn_count: usize,
422 output_tokens: u64,
423 context_tokens: u64,
424 cost: f64,
425 cost_per_turn: f64,
426}
427
428fn build_trend_json(trend: &TrendResult) -> TrendJson {
430 TrendJson {
431 group_label: trend.group_label.clone(),
432 entries: trend
433 .entries
434 .iter()
435 .map(|e| {
436 let cpt = if e.turn_count > 0 {
437 e.cost / e.turn_count as f64
438 } else {
439 0.0
440 };
441 TrendEntryJson {
442 label: e.label.clone(),
443 session_count: e.session_count,
444 turn_count: e.turn_count,
445 output_tokens: e.tokens.output_tokens,
446 context_tokens: e.tokens.context_tokens(),
447 cost: e.cost,
448 cost_per_turn: cpt,
449 }
450 })
451 .collect(),
452 }
453}
454
455pub fn render_trend_json(trend: &TrendResult) -> String {
456 let json = build_trend_json(trend);
457 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
458}
459
460pub fn render_wrapped_json(result: &WrappedResult) -> String {
463 serde_json::to_string_pretty(result).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
464}
465
466#[derive(Serialize)]
469#[serde(rename_all = "camelCase")]
470struct HeatmapJson {
471 start_date: String,
472 end_date: String,
473 thresholds: [usize; 3],
475 daily: Vec<DailyActivityJson>,
476 stats: HeatmapStatsJson,
477}
478
479#[derive(Serialize)]
480#[serde(rename_all = "camelCase")]
481struct DailyActivityJson {
482 date: String,
483 turns: usize,
484 cost: f64,
485 sessions: usize,
486}
487
488#[derive(Serialize)]
489#[serde(rename_all = "camelCase")]
490struct HeatmapStatsJson {
491 total_days: usize,
492 active_days: usize,
493 current_streak: usize,
494 longest_streak: usize,
495 #[serde(skip_serializing_if = "Option::is_none")]
497 busiest_day: Option<BusiestDayJson>,
498}
499
500#[derive(Serialize)]
501#[serde(rename_all = "camelCase")]
502struct BusiestDayJson {
503 date: String,
504 turns: usize,
505}
506
507pub fn render_heatmap_json(result: &HeatmapResult) -> String {
508 let (p25, p50, p75) = result.thresholds;
509 let json = HeatmapJson {
510 start_date: result.start_date.to_string(),
511 end_date: result.end_date.to_string(),
512 thresholds: [p25, p50, p75],
513 daily: result
514 .daily
515 .iter()
516 .map(|d| DailyActivityJson {
517 date: d.date.to_string(),
518 turns: d.turns,
519 cost: d.cost,
520 sessions: d.sessions,
521 })
522 .collect(),
523 stats: HeatmapStatsJson {
524 total_days: result.stats.total_days,
525 active_days: result.stats.active_days,
526 current_streak: result.stats.current_streak,
527 longest_streak: result.stats.longest_streak,
528 busiest_day: result.stats.busiest_day.map(|(d, n)| BusiestDayJson {
529 date: d.to_string(),
530 turns: n,
531 }),
532 },
533 };
534 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
535}
536
537#[derive(Serialize)]
542pub struct HtmlReportPayload {
543 pub overview: serde_json::Value,
544 pub projects: serde_json::Value,
545 pub trends: serde_json::Value,
546 pub sessions: Vec<HtmlSessionSummary>,
547 pub heatmap: HeatmapPayload,
548 #[serde(skip_serializing_if = "Option::is_none")]
549 pub wrapped: Option<serde_json::Value>,
550 #[serde(skip_serializing_if = "Option::is_none")]
551 pub active_session_id: Option<String>,
552}
553
554#[derive(Serialize)]
556pub struct HtmlSessionSummary {
557 pub id: String,
558 pub project: Option<String>,
559 pub turns: usize,
560 #[serde(rename = "agentTurnCount")]
561 pub agent_turn_count: u64,
562 pub cost: f64,
563 pub duration_minutes: Option<f64>,
564 pub model: Option<String>,
565 pub cache_hit_rate: Option<f64>,
566 #[serde(skip_serializing_if = "Option::is_none")]
567 pub first_timestamp: Option<String>,
568 #[serde(skip_serializing_if = "Option::is_none")]
569 pub last_timestamp: Option<String>,
570 pub title: Option<String>,
572 #[serde(skip_serializing_if = "Vec::is_empty")]
573 pub tags: Vec<String>,
574 pub mode: Option<String>,
575 pub subagents: Vec<HtmlSubagentSummary>,
578 pub plugins: Vec<PluginUsage>,
579 pub skills: Vec<SkillUsage>,
580 pub hooks: Vec<HookUsage>,
581 #[serde(rename = "subagentTypes")]
583 pub subagent_types: Vec<SubagentTypeAggregate>,
584 #[serde(rename = "isOrphan")]
587 pub is_orphan: bool,
588}
589
590#[derive(Serialize)]
594#[serde(rename_all = "camelCase")]
595pub struct HtmlSubagentSummary {
596 pub agent_id: String,
597 #[serde(skip_serializing_if = "Option::is_none")]
598 pub agent_type: Option<String>,
599 #[serde(skip_serializing_if = "Option::is_none")]
600 pub description: Option<String>,
601 pub turns: usize,
602 pub output_tokens: u64,
603 pub cost: f64,
604 #[serde(skip_serializing_if = "Option::is_none")]
605 pub first_timestamp: Option<String>,
606 #[serde(skip_serializing_if = "Option::is_none")]
607 pub last_timestamp: Option<String>,
608}
609
610#[derive(Serialize)]
612pub struct HeatmapPayload {
613 pub days: Vec<DailyActivity>,
614}
615
616#[derive(Serialize)]
618pub struct DailyActivity {
619 pub date: String,
620 pub turns: usize,
621 pub cost: f64,
622 pub sessions: usize,
623}
624
625pub fn render_html_payload(
630 overview: &OverviewResult,
631 projects: &ProjectResult,
632 trend: &TrendResult,
633 sessions: &[SessionData],
634 calc: &PricingCalculator,
635 wrapped: Option<&WrappedResult>,
636 active_session_id: Option<&str>,
637) -> String {
638 let overview_json: serde_json::Value =
640 serde_json::to_value(build_overview_json(overview)).unwrap_or(serde_json::Value::Null);
641 let projects_json: serde_json::Value =
642 serde_json::to_value(build_projects_json(projects)).unwrap_or(serde_json::Value::Null);
643 let trends_json: serde_json::Value =
644 serde_json::to_value(build_trend_json(trend)).unwrap_or(serde_json::Value::Null);
645
646 let session_summaries: Vec<HtmlSessionSummary> = sessions
648 .iter()
649 .map(|s| build_html_session_summary(s, calc))
650 .collect();
651
652 let heatmap = build_heatmap(sessions, calc);
654
655 let wrapped_json: Option<serde_json::Value> =
657 wrapped.and_then(|w| serde_json::to_value(w).ok());
658
659 let payload = HtmlReportPayload {
660 overview: overview_json,
661 projects: projects_json,
662 trends: trends_json,
663 sessions: session_summaries,
664 heatmap,
665 wrapped: wrapped_json,
666 active_session_id: active_session_id.map(|s| s.to_string()),
667 };
668
669 serde_json::to_string(&payload).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
670}
671
672fn build_html_session_summary(
674 session: &SessionData,
675 calc: &PricingCalculator,
676) -> HtmlSessionSummary {
677 let all = session.all_responses();
678 let turn_count = all.len();
679 let agent_turn_count = session.agent_turn_count();
680
681 let mut total_cost = 0.0;
683 let mut total_cache_read: u64 = 0;
684 let mut total_context: u64 = 0;
685 let mut model_counts: HashMap<&str, usize> = HashMap::new();
686
687 for turn in &all {
688 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
689 total_cost += cost.total;
690
691 let input = turn.usage.input_tokens.unwrap_or(0);
692 let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
693 let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
694 let ctx = input + cache_create + cache_read;
695
696 total_context += ctx;
697 total_cache_read += cache_read;
698
699 *model_counts.entry(&turn.model).or_insert(0) += 1;
700 }
701
702 let cache_hit_rate = if total_context > 0 {
703 Some((total_cache_read as f64 / total_context as f64) * 100.0)
704 } else {
705 None
706 };
707
708 let primary_model = model_counts
709 .into_iter()
710 .max_by_key(|(_, count)| *count)
711 .map(|(m, _)| m.to_string());
712
713 let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
714 (Some(first), Some(last)) => Some((last - first).num_seconds() as f64 / 60.0),
715 _ => None,
716 };
717
718 let subagents: Vec<HtmlSubagentSummary> = session
719 .subagents
720 .iter()
721 .map(|sa| {
722 let mut output_tokens: u64 = 0;
723 let mut sa_cost = 0.0f64;
724 for t in &sa.turns {
725 output_tokens += t.usage.output_tokens.unwrap_or(0);
726 sa_cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
727 }
728 HtmlSubagentSummary {
729 agent_id: sa.agent_id.clone(),
730 agent_type: sa.agent_type.clone(),
731 description: sa.description.clone(),
732 turns: sa.turns.len(),
733 output_tokens,
734 cost: sa_cost,
735 first_timestamp: sa.first_timestamp.map(|t| t.to_rfc3339()),
736 last_timestamp: sa.last_timestamp.map(|t| t.to_rfc3339()),
737 }
738 })
739 .collect();
740
741 let subagent_types = session.subagent_type_aggregates(calc);
742
743 HtmlSessionSummary {
744 id: session.session_id.clone(),
745 project: session.project.as_deref().map(project_display_name),
746 turns: turn_count,
747 agent_turn_count: agent_turn_count as u64,
748 cost: total_cost,
749 duration_minutes,
750 model: primary_model,
751 cache_hit_rate,
752 first_timestamp: session.first_timestamp.map(|t| t.to_rfc3339()),
753 last_timestamp: session.last_timestamp.map(|t| t.to_rfc3339()),
754 title: session.metadata.title.clone(),
755 tags: session.metadata.tags.clone(),
756 mode: session.metadata.mode.clone(),
757 subagents,
758 plugins: session.plugins.clone(),
759 skills: session.skills.clone(),
760 hooks: session.hooks.clone(),
761 subagent_types,
762 is_orphan: session.is_orphan,
763 }
764}
765
766fn build_heatmap(sessions: &[SessionData], calc: &PricingCalculator) -> HeatmapPayload {
773 let mut daily_map: HashMap<String, (usize, f64, usize)> = HashMap::new();
775
776 for session in sessions {
777 if let Some(ts) = session.first_timestamp {
780 let local = ts.with_timezone(&chrono::Local);
781 let date_key = format!(
782 "{:04}-{:02}-{:02}",
783 local.year(),
784 local.month(),
785 local.day()
786 );
787 daily_map.entry(date_key).or_insert((0, 0.0, 0)).2 += 1;
788 }
789
790 for turn in session.all_responses() {
791 let local = turn.timestamp.with_timezone(&chrono::Local);
792 let date_key = format!(
793 "{:04}-{:02}-{:02}",
794 local.year(),
795 local.month(),
796 local.day()
797 );
798 let entry = daily_map.entry(date_key).or_insert((0, 0.0, 0));
799 entry.0 += 1;
800 entry.1 += calc.calculate_turn_cost(&turn.model, &turn.usage).total;
801 }
802 }
803
804 let mut days: Vec<DailyActivity> = daily_map
805 .into_iter()
806 .map(|(date, (turns, cost, session_count))| DailyActivity {
807 date,
808 turns,
809 cost,
810 sessions: session_count,
811 })
812 .collect();
813
814 days.sort_by(|a, b| a.date.cmp(&b.date));
816
817 HeatmapPayload { days }
818}
819
820#[cfg(test)]
823mod tests {
824 use super::*;
825 use crate::analysis::heatmap::analyze_heatmap;
826 use crate::data::models::{
827 DataQuality, SessionData, SessionMetadata, TokenUsage, ValidatedTurn,
828 };
829 use chrono::{DateTime, Local, TimeZone, Utc};
830
831 fn make_turn(ts: &str) -> ValidatedTurn {
832 ValidatedTurn {
833 uuid: format!("u-{ts}"),
834 request_id: Some(format!("r-{ts}")),
835 timestamp: ts.parse::<DateTime<Utc>>().unwrap(),
836 model: "claude-sonnet-4-20250514".into(),
837 usage: TokenUsage {
838 input_tokens: Some(10),
839 output_tokens: Some(20),
840 cache_creation_input_tokens: Some(0),
841 cache_read_input_tokens: Some(0),
842 cache_creation: None,
843 server_tool_use: None,
844 service_tier: None,
845 speed: None,
846 inference_geo: None,
847 },
848 stop_reason: None,
849 content_types: vec!["text".into()],
850 is_agent: false,
851 agent_id: None,
852 user_text: None,
853 assistant_text: None,
854 tool_names: vec![],
855 service_tier: None,
856 speed: None,
857 inference_geo: None,
858 tool_error_count: 0,
859 git_branch: None,
860 attribution_plugin: None,
861 attribution_skill: None,
862 }
863 }
864
865 fn make_session(id: &str, turns: Vec<ValidatedTurn>) -> SessionData {
866 let first = turns.iter().map(|t| t.timestamp).min();
867 let last = turns.iter().map(|t| t.timestamp).max();
868 SessionData {
869 session_id: id.into(),
870 project: Some("test".into()),
871 turns,
872 subagents: vec![],
873 plugins: vec![],
874 skills: vec![],
875 hooks: vec![],
876 first_timestamp: first,
877 last_timestamp: last,
878 version: None,
879 quality: DataQuality::default(),
880 metadata: SessionMetadata::default(),
881 is_orphan: false,
882 }
883 }
884
885 #[test]
890 fn heatmap_html_payload_attributes_turns_per_day() {
891 let calc = PricingCalculator::new();
892 let local_today = Local::now().date_naive();
894 let day_a = local_today - chrono::Duration::days(2);
895 let day_b = local_today - chrono::Duration::days(1);
896 let ts_a: DateTime<Utc> = Local
898 .from_local_datetime(&day_a.and_hms_opt(12, 0, 0).unwrap())
899 .single()
900 .unwrap()
901 .with_timezone(&Utc);
902 let ts_b: DateTime<Utc> = Local
903 .from_local_datetime(&day_b.and_hms_opt(12, 0, 0).unwrap())
904 .single()
905 .unwrap()
906 .with_timezone(&Utc);
907
908 let sessions = vec![make_session(
909 "s1",
910 vec![
911 make_turn(&ts_a.to_rfc3339()),
912 make_turn(&ts_a.to_rfc3339()),
913 make_turn(&ts_b.to_rfc3339()),
914 ],
915 )];
916
917 let hm = build_heatmap(&sessions, &calc);
918 let day_a_str = day_a.to_string();
920 let day_b_str = day_b.to_string();
921 let entry_a = hm.days.iter().find(|d| d.date == day_a_str).unwrap();
922 let entry_b = hm.days.iter().find(|d| d.date == day_b_str).unwrap();
923 assert_eq!(
924 entry_a.turns, 2,
925 "two turns at 12:00 local on day_a must stay on day_a"
926 );
927 assert_eq!(
928 entry_b.turns, 1,
929 "one turn at 12:00 local on day_b must be attributed to day_b"
930 );
931 assert_eq!(entry_a.sessions, 1);
933 assert_eq!(entry_b.sessions, 0);
934 }
935
936 #[test]
940 fn heatmap_json_output_has_expected_shape() {
941 let calc = PricingCalculator::new();
942 let local_today = Local::now().date_naive();
944 let yesterday = local_today - chrono::Duration::days(1);
945 let ts: DateTime<Utc> = Local
946 .from_local_datetime(&yesterday.and_hms_opt(12, 0, 0).unwrap())
947 .single()
948 .unwrap()
949 .with_timezone(&Utc);
950 let sessions = vec![make_session("s1", vec![make_turn(&ts.to_rfc3339())])];
951
952 let result = analyze_heatmap(&sessions, &calc, 7);
953 let json_str = render_heatmap_json(&result);
954 let v: serde_json::Value = serde_json::from_str(&json_str).expect("must parse as JSON");
955 assert!(v.get("daily").and_then(|d| d.as_array()).is_some());
956 assert!(v.get("startDate").and_then(|s| s.as_str()).is_some());
957 assert!(v.get("endDate").and_then(|s| s.as_str()).is_some());
958 assert!(v.get("thresholds").and_then(|t| t.as_array()).is_some());
959 assert!(v.get("stats").is_some());
960 let first = &v["daily"][0];
962 assert!(first.get("date").is_some());
963 assert!(first.get("turns").is_some());
964 assert!(first.get("cost").is_some());
965 assert!(first.get("sessions").is_some());
966 let yesterday_str = yesterday.to_string();
968 let y_entry = v["daily"]
969 .as_array()
970 .unwrap()
971 .iter()
972 .find(|d| d["date"].as_str() == Some(&yesterday_str))
973 .expect("yesterday must be in the heatmap range");
974 assert_eq!(y_entry["turns"].as_u64(), Some(1));
975 let bd = &v["stats"]["busiestDay"];
977 assert_eq!(bd["date"].as_str(), Some(yesterday_str.as_str()));
978 assert_eq!(bd["turns"].as_u64(), Some(1));
979 }
980
981 #[test]
984 fn html_report_payload_includes_heatmap_section() {
985 use crate::analysis::overview::analyze_overview;
986 use crate::analysis::project::analyze_projects;
987 use crate::analysis::trend::analyze_trend;
988 use crate::data::models::GlobalDataQuality;
989
990 let calc = PricingCalculator::new();
991 let local_today = Local::now().date_naive();
992 let ts: DateTime<Utc> = Local
993 .from_local_datetime(&local_today.and_hms_opt(12, 0, 0).unwrap())
994 .single()
995 .unwrap()
996 .with_timezone(&Utc);
997 let sessions = vec![make_session("s1", vec![make_turn(&ts.to_rfc3339())])];
998
999 let overview = analyze_overview(&sessions, GlobalDataQuality::default(), &calc, None);
1000 let projects = analyze_projects(&sessions, &calc, 10);
1001 let trend = analyze_trend(&sessions, &calc, 0, false);
1002 let payload =
1003 render_html_payload(&overview, &projects, &trend, &sessions, &calc, None, None);
1004 let v: serde_json::Value = serde_json::from_str(&payload).expect("must parse as JSON");
1005 let days = v["heatmap"]["days"]
1006 .as_array()
1007 .expect("heatmap.days must be an array");
1008 assert!(
1009 days.iter().any(|d| d["turns"].as_u64() == Some(1)),
1010 "the one turn we wrote must appear in heatmap.days"
1011 );
1012 }
1013}