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, WorkflowSummary};
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 workflows: Vec<WorkflowSummary>,
257 #[serde(rename = "isOrphan")]
260 is_orphan: bool,
261}
262
263#[derive(Serialize)]
267#[serde(rename_all = "camelCase")]
268struct SubagentJson {
269 agent_id: String,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 agent_type: Option<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 description: Option<String>,
274 turns: usize,
275 output_tokens: u64,
276 cost: f64,
277}
278
279#[derive(Serialize)]
280struct TurnJson {
281 turn_number: usize,
282 timestamp: String,
283 model: String,
284 input_tokens: u64,
285 output_tokens: u64,
286 cache_read_tokens: u64,
287 context_size: u64,
288 cache_hit_rate: f64,
289 cost: f64,
290 #[serde(skip_serializing_if = "Option::is_none")]
291 stop_reason: Option<String>,
292 is_agent: bool,
293 is_compaction: bool,
294 #[serde(skip_serializing_if = "Vec::is_empty")]
295 tool_names: Vec<String>,
296}
297
298pub fn render_session_json(result: &SessionResult) -> String {
299 let ctx = result.total_tokens.context_tokens();
300 let cache_hit_rate = if ctx > 0 {
301 result.total_tokens.cache_read_tokens as f64 / ctx as f64 * 100.0
302 } else {
303 0.0
304 };
305
306 let turns: Vec<TurnJson> = result
307 .turn_details
308 .iter()
309 .map(|t| TurnJson {
310 turn_number: t.turn_number,
311 timestamp: t.timestamp.to_rfc3339(),
312 model: t.model.clone(),
313 input_tokens: t.input_tokens,
314 output_tokens: t.output_tokens,
315 cache_read_tokens: t.cache_read_tokens,
316 context_size: t.context_size,
317 cache_hit_rate: t.cache_hit_rate,
318 cost: t.cost,
319 stop_reason: t.stop_reason.clone(),
320 is_agent: t.is_agent,
321 is_compaction: t.is_compaction,
322 tool_names: t.tool_names.clone(),
323 })
324 .collect();
325
326 let subagents: Vec<SubagentJson> = result
327 .subagents
328 .iter()
329 .map(|s| SubagentJson {
330 agent_id: s.agent_id.clone(),
331 agent_type: s.agent_type.clone(),
332 description: s.description.clone(),
333 turns: s.turns,
334 output_tokens: s.output_tokens,
335 cost: s.cost,
336 })
337 .collect();
338
339 let json = SessionJson {
340 session_id: result.session_id.clone(),
341 project: result.project.clone(),
342 model: result.model.clone(),
343 duration_minutes: result.duration_minutes,
344 total_cost: result.total_cost,
345 max_context: result.max_context,
346 compaction_count: result.compaction_count,
347 output_tokens: result.total_tokens.output_tokens,
348 context_tokens: ctx,
349 cache_hit_rate,
350 agent_turn_count: result.agent_summary.total_agent_turns as u64,
351 agent_output_tokens: result.agent_summary.agent_output_tokens,
352 agent_cost: result.agent_summary.agent_cost,
353 title: result.title.clone(),
354 tags: result.tags.clone(),
355 turns,
356 subagents,
357 plugins: result.plugins.clone(),
358 skills: result.skills.clone(),
359 hooks: result.hooks.clone(),
360 subagent_types: result.subagent_types.clone(),
361 workflows: result.workflows.clone(),
362 is_orphan: result.is_orphan,
363 };
364
365 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
366}
367
368#[derive(Serialize)]
371struct ProjectsJson {
372 projects: Vec<ProjectJson>,
373}
374
375#[derive(Serialize)]
376struct ProjectJson {
377 name: String,
378 display_name: String,
379 session_count: usize,
380 total_turns: usize,
381 agent_turns: usize,
382 output_tokens: u64,
383 context_tokens: u64,
384 cost: f64,
385 primary_model: String,
386}
387
388fn build_projects_json(projects: &ProjectResult) -> ProjectsJson {
390 ProjectsJson {
391 projects: projects
392 .projects
393 .iter()
394 .map(|p| ProjectJson {
395 name: p.name.clone(),
396 display_name: p.display_name.clone(),
397 session_count: p.session_count,
398 total_turns: p.total_turns,
399 agent_turns: p.agent_turns,
400 output_tokens: p.tokens.output_tokens,
401 context_tokens: p.tokens.context_tokens(),
402 cost: p.cost,
403 primary_model: p.primary_model.clone(),
404 })
405 .collect(),
406 }
407}
408
409pub fn render_projects_json(projects: &ProjectResult) -> String {
410 let json = build_projects_json(projects);
411 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
412}
413
414#[derive(Serialize)]
417struct TrendJson {
418 group_label: String,
419 entries: Vec<TrendEntryJson>,
420}
421
422#[derive(Serialize)]
423struct TrendEntryJson {
424 label: String,
425 session_count: usize,
426 turn_count: usize,
427 output_tokens: u64,
428 context_tokens: u64,
429 cost: f64,
430 cost_per_turn: f64,
431}
432
433fn build_trend_json(trend: &TrendResult) -> TrendJson {
435 TrendJson {
436 group_label: trend.group_label.clone(),
437 entries: trend
438 .entries
439 .iter()
440 .map(|e| {
441 let cpt = if e.turn_count > 0 {
442 e.cost / e.turn_count as f64
443 } else {
444 0.0
445 };
446 TrendEntryJson {
447 label: e.label.clone(),
448 session_count: e.session_count,
449 turn_count: e.turn_count,
450 output_tokens: e.tokens.output_tokens,
451 context_tokens: e.tokens.context_tokens(),
452 cost: e.cost,
453 cost_per_turn: cpt,
454 }
455 })
456 .collect(),
457 }
458}
459
460pub fn render_trend_json(trend: &TrendResult) -> String {
461 let json = build_trend_json(trend);
462 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
463}
464
465pub fn render_wrapped_json(result: &WrappedResult) -> String {
468 serde_json::to_string_pretty(result).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
469}
470
471#[derive(Serialize)]
474#[serde(rename_all = "camelCase")]
475struct HeatmapJson {
476 start_date: String,
477 end_date: String,
478 thresholds: [usize; 3],
480 daily: Vec<DailyActivityJson>,
481 stats: HeatmapStatsJson,
482}
483
484#[derive(Serialize)]
485#[serde(rename_all = "camelCase")]
486struct DailyActivityJson {
487 date: String,
488 turns: usize,
489 cost: f64,
490 sessions: usize,
491}
492
493#[derive(Serialize)]
494#[serde(rename_all = "camelCase")]
495struct HeatmapStatsJson {
496 total_days: usize,
497 active_days: usize,
498 current_streak: usize,
499 longest_streak: usize,
500 #[serde(skip_serializing_if = "Option::is_none")]
502 busiest_day: Option<BusiestDayJson>,
503}
504
505#[derive(Serialize)]
506#[serde(rename_all = "camelCase")]
507struct BusiestDayJson {
508 date: String,
509 turns: usize,
510}
511
512pub fn render_heatmap_json(result: &HeatmapResult) -> String {
513 let (p25, p50, p75) = result.thresholds;
514 let json = HeatmapJson {
515 start_date: result.start_date.to_string(),
516 end_date: result.end_date.to_string(),
517 thresholds: [p25, p50, p75],
518 daily: result
519 .daily
520 .iter()
521 .map(|d| DailyActivityJson {
522 date: d.date.to_string(),
523 turns: d.turns,
524 cost: d.cost,
525 sessions: d.sessions,
526 })
527 .collect(),
528 stats: HeatmapStatsJson {
529 total_days: result.stats.total_days,
530 active_days: result.stats.active_days,
531 current_streak: result.stats.current_streak,
532 longest_streak: result.stats.longest_streak,
533 busiest_day: result.stats.busiest_day.map(|(d, n)| BusiestDayJson {
534 date: d.to_string(),
535 turns: n,
536 }),
537 },
538 };
539 serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
540}
541
542#[derive(Serialize)]
547pub struct HtmlReportPayload {
548 pub overview: serde_json::Value,
549 pub projects: serde_json::Value,
550 pub trends: serde_json::Value,
551 pub sessions: Vec<HtmlSessionSummary>,
552 pub heatmap: HeatmapPayload,
553 #[serde(skip_serializing_if = "Option::is_none")]
554 pub wrapped: Option<serde_json::Value>,
555 #[serde(skip_serializing_if = "Option::is_none")]
556 pub active_session_id: Option<String>,
557}
558
559#[derive(Serialize)]
561pub struct HtmlSessionSummary {
562 pub id: String,
563 pub project: Option<String>,
564 pub turns: usize,
565 #[serde(rename = "agentTurnCount")]
566 pub agent_turn_count: u64,
567 pub cost: f64,
568 pub duration_minutes: Option<f64>,
569 pub model: Option<String>,
570 pub cache_hit_rate: Option<f64>,
571 #[serde(skip_serializing_if = "Option::is_none")]
572 pub first_timestamp: Option<String>,
573 #[serde(skip_serializing_if = "Option::is_none")]
574 pub last_timestamp: Option<String>,
575 pub title: Option<String>,
577 #[serde(skip_serializing_if = "Vec::is_empty")]
578 pub tags: Vec<String>,
579 pub mode: Option<String>,
580 pub subagents: Vec<HtmlSubagentSummary>,
583 pub plugins: Vec<PluginUsage>,
584 pub skills: Vec<SkillUsage>,
585 pub hooks: Vec<HookUsage>,
586 #[serde(rename = "subagentTypes")]
588 pub subagent_types: Vec<SubagentTypeAggregate>,
589 pub workflows: Vec<WorkflowSummary>,
595 #[serde(rename = "isOrphan")]
598 pub is_orphan: bool,
599}
600
601#[derive(Serialize)]
605#[serde(rename_all = "camelCase")]
606pub struct HtmlSubagentSummary {
607 pub agent_id: String,
608 #[serde(skip_serializing_if = "Option::is_none")]
609 pub agent_type: Option<String>,
610 #[serde(skip_serializing_if = "Option::is_none")]
611 pub description: Option<String>,
612 pub turns: usize,
613 pub output_tokens: u64,
614 pub cost: f64,
615 #[serde(skip_serializing_if = "Option::is_none")]
616 pub first_timestamp: Option<String>,
617 #[serde(skip_serializing_if = "Option::is_none")]
618 pub last_timestamp: Option<String>,
619}
620
621#[derive(Serialize)]
623pub struct HeatmapPayload {
624 pub days: Vec<DailyActivity>,
625}
626
627#[derive(Serialize)]
629pub struct DailyActivity {
630 pub date: String,
631 pub turns: usize,
632 pub cost: f64,
633 pub sessions: usize,
634}
635
636#[allow(clippy::too_many_arguments)]
641pub fn render_html_payload(
642 overview: &OverviewResult,
643 projects: &ProjectResult,
644 trend: &TrendResult,
645 sessions: &[SessionData],
646 calc: &PricingCalculator,
647 wrapped: Option<&WrappedResult>,
648 active_session_id: Option<&str>,
649 claude_home: &std::path::Path,
650) -> String {
651 let overview_json: serde_json::Value =
653 serde_json::to_value(build_overview_json(overview)).unwrap_or(serde_json::Value::Null);
654 let projects_json: serde_json::Value =
655 serde_json::to_value(build_projects_json(projects)).unwrap_or(serde_json::Value::Null);
656 let trends_json: serde_json::Value =
657 serde_json::to_value(build_trend_json(trend)).unwrap_or(serde_json::Value::Null);
658
659 let session_summaries: Vec<HtmlSessionSummary> = sessions
661 .iter()
662 .map(|s| build_html_session_summary(s, calc, claude_home))
663 .collect();
664
665 let heatmap = build_heatmap(sessions, calc);
667
668 let wrapped_json: Option<serde_json::Value> =
670 wrapped.and_then(|w| serde_json::to_value(w).ok());
671
672 let payload = HtmlReportPayload {
673 overview: overview_json,
674 projects: projects_json,
675 trends: trends_json,
676 sessions: session_summaries,
677 heatmap,
678 wrapped: wrapped_json,
679 active_session_id: active_session_id.map(|s| s.to_string()),
680 };
681
682 serde_json::to_string(&payload).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
683}
684
685fn build_html_session_summary(
687 session: &SessionData,
688 calc: &PricingCalculator,
689 claude_home: &std::path::Path,
690) -> HtmlSessionSummary {
691 let all = session.all_responses();
692 let turn_count = all.len();
693 let agent_turn_count = session.agent_turn_count();
694
695 let mut total_cost = 0.0;
697 let mut total_cache_read: u64 = 0;
698 let mut total_context: u64 = 0;
699 let mut model_counts: HashMap<&str, usize> = HashMap::new();
700
701 for turn in &all {
702 let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
703 total_cost += cost.total;
704
705 let input = turn.usage.input_tokens.unwrap_or(0);
706 let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
707 let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
708 let ctx = input + cache_create + cache_read;
709
710 total_context += ctx;
711 total_cache_read += cache_read;
712
713 *model_counts.entry(&turn.model).or_insert(0) += 1;
714 }
715
716 let cache_hit_rate = if total_context > 0 {
717 Some((total_cache_read as f64 / total_context as f64) * 100.0)
718 } else {
719 None
720 };
721
722 let primary_model = model_counts
723 .into_iter()
724 .max_by_key(|(_, count)| *count)
725 .map(|(m, _)| m.to_string());
726
727 let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
728 (Some(first), Some(last)) => Some((last - first).num_seconds() as f64 / 60.0),
729 _ => None,
730 };
731
732 let subagents: Vec<HtmlSubagentSummary> = session
733 .subagents
734 .iter()
735 .map(|sa| {
736 let mut output_tokens: u64 = 0;
737 let mut sa_cost = 0.0f64;
738 for t in &sa.turns {
739 output_tokens += t.usage.output_tokens.unwrap_or(0);
740 sa_cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
741 }
742 HtmlSubagentSummary {
743 agent_id: sa.agent_id.clone(),
744 agent_type: sa.agent_type.clone(),
745 description: sa.description.clone(),
746 turns: sa.turns.len(),
747 output_tokens,
748 cost: sa_cost,
749 first_timestamp: sa.first_timestamp.map(|t| t.to_rfc3339()),
750 last_timestamp: sa.last_timestamp.map(|t| t.to_rfc3339()),
751 }
752 })
753 .collect();
754
755 let subagent_types = session.subagent_type_aggregates(calc);
756 let workflows = crate::analysis::session::build_workflow_summaries(session, calc, claude_home);
757
758 HtmlSessionSummary {
759 id: session.session_id.clone(),
760 project: session.project.as_deref().map(project_display_name),
761 turns: turn_count,
762 agent_turn_count: agent_turn_count as u64,
763 cost: total_cost,
764 duration_minutes,
765 model: primary_model,
766 cache_hit_rate,
767 first_timestamp: session.first_timestamp.map(|t| t.to_rfc3339()),
768 last_timestamp: session.last_timestamp.map(|t| t.to_rfc3339()),
769 title: session.metadata.title.clone(),
770 tags: session.metadata.tags.clone(),
771 mode: session.metadata.mode.clone(),
772 subagents,
773 plugins: session.plugins.clone(),
774 skills: session.skills.clone(),
775 hooks: session.hooks.clone(),
776 subagent_types,
777 workflows,
778 is_orphan: session.is_orphan,
779 }
780}
781
782fn build_heatmap(sessions: &[SessionData], calc: &PricingCalculator) -> HeatmapPayload {
789 let mut daily_map: HashMap<String, (usize, f64, usize)> = HashMap::new();
791
792 for session in sessions {
793 if let Some(ts) = session.first_timestamp {
796 let local = ts.with_timezone(&chrono::Local);
797 let date_key = format!(
798 "{:04}-{:02}-{:02}",
799 local.year(),
800 local.month(),
801 local.day()
802 );
803 daily_map.entry(date_key).or_insert((0, 0.0, 0)).2 += 1;
804 }
805
806 for turn in session.all_responses() {
807 let local = turn.timestamp.with_timezone(&chrono::Local);
808 let date_key = format!(
809 "{:04}-{:02}-{:02}",
810 local.year(),
811 local.month(),
812 local.day()
813 );
814 let entry = daily_map.entry(date_key).or_insert((0, 0.0, 0));
815 entry.0 += 1;
816 entry.1 += calc.calculate_turn_cost(&turn.model, &turn.usage).total;
817 }
818 }
819
820 let mut days: Vec<DailyActivity> = daily_map
821 .into_iter()
822 .map(|(date, (turns, cost, session_count))| DailyActivity {
823 date,
824 turns,
825 cost,
826 sessions: session_count,
827 })
828 .collect();
829
830 days.sort_by(|a, b| a.date.cmp(&b.date));
832
833 HeatmapPayload { days }
834}
835
836#[cfg(test)]
839mod tests {
840 use super::*;
841 use crate::analysis::heatmap::analyze_heatmap;
842 use crate::data::models::{
843 DataQuality, SessionData, SessionMetadata, TokenUsage, ValidatedTurn,
844 };
845 use chrono::{DateTime, Local, TimeZone, Utc};
846
847 fn make_turn(ts: &str) -> ValidatedTurn {
848 ValidatedTurn {
849 uuid: format!("u-{ts}"),
850 request_id: Some(format!("r-{ts}")),
851 timestamp: ts.parse::<DateTime<Utc>>().unwrap(),
852 model: "claude-sonnet-4-20250514".into(),
853 usage: TokenUsage {
854 input_tokens: Some(10),
855 output_tokens: Some(20),
856 cache_creation_input_tokens: Some(0),
857 cache_read_input_tokens: Some(0),
858 cache_creation: None,
859 server_tool_use: None,
860 service_tier: None,
861 speed: None,
862 inference_geo: None,
863 },
864 stop_reason: None,
865 content_types: vec!["text".into()],
866 is_agent: false,
867 agent_id: None,
868 user_text: None,
869 assistant_text: None,
870 tool_names: vec![],
871 service_tier: None,
872 speed: None,
873 inference_geo: None,
874 tool_error_count: 0,
875 git_branch: None,
876 attribution_plugin: None,
877 attribution_skill: None,
878 }
879 }
880
881 fn make_session(id: &str, turns: Vec<ValidatedTurn>) -> SessionData {
882 let first = turns.iter().map(|t| t.timestamp).min();
883 let last = turns.iter().map(|t| t.timestamp).max();
884 SessionData {
885 session_id: id.into(),
886 project: Some("test".into()),
887 turns,
888 subagents: vec![],
889 plugins: vec![],
890 skills: vec![],
891 hooks: vec![],
892 first_timestamp: first,
893 last_timestamp: last,
894 version: None,
895 quality: DataQuality::default(),
896 metadata: SessionMetadata::default(),
897 is_orphan: false,
898 }
899 }
900
901 #[test]
906 fn heatmap_html_payload_attributes_turns_per_day() {
907 let calc = PricingCalculator::new();
908 let local_today = Local::now().date_naive();
910 let day_a = local_today - chrono::Duration::days(2);
911 let day_b = local_today - chrono::Duration::days(1);
912 let ts_a: DateTime<Utc> = Local
914 .from_local_datetime(&day_a.and_hms_opt(12, 0, 0).unwrap())
915 .single()
916 .unwrap()
917 .with_timezone(&Utc);
918 let ts_b: DateTime<Utc> = Local
919 .from_local_datetime(&day_b.and_hms_opt(12, 0, 0).unwrap())
920 .single()
921 .unwrap()
922 .with_timezone(&Utc);
923
924 let sessions = vec![make_session(
925 "s1",
926 vec![
927 make_turn(&ts_a.to_rfc3339()),
928 make_turn(&ts_a.to_rfc3339()),
929 make_turn(&ts_b.to_rfc3339()),
930 ],
931 )];
932
933 let hm = build_heatmap(&sessions, &calc);
934 let day_a_str = day_a.to_string();
936 let day_b_str = day_b.to_string();
937 let entry_a = hm.days.iter().find(|d| d.date == day_a_str).unwrap();
938 let entry_b = hm.days.iter().find(|d| d.date == day_b_str).unwrap();
939 assert_eq!(
940 entry_a.turns, 2,
941 "two turns at 12:00 local on day_a must stay on day_a"
942 );
943 assert_eq!(
944 entry_b.turns, 1,
945 "one turn at 12:00 local on day_b must be attributed to day_b"
946 );
947 assert_eq!(entry_a.sessions, 1);
949 assert_eq!(entry_b.sessions, 0);
950 }
951
952 #[test]
956 fn heatmap_json_output_has_expected_shape() {
957 let calc = PricingCalculator::new();
958 let local_today = Local::now().date_naive();
960 let yesterday = local_today - chrono::Duration::days(1);
961 let ts: DateTime<Utc> = Local
962 .from_local_datetime(&yesterday.and_hms_opt(12, 0, 0).unwrap())
963 .single()
964 .unwrap()
965 .with_timezone(&Utc);
966 let sessions = vec![make_session("s1", vec![make_turn(&ts.to_rfc3339())])];
967
968 let result = analyze_heatmap(&sessions, &calc, 7);
969 let json_str = render_heatmap_json(&result);
970 let v: serde_json::Value = serde_json::from_str(&json_str).expect("must parse as JSON");
971 assert!(v.get("daily").and_then(|d| d.as_array()).is_some());
972 assert!(v.get("startDate").and_then(|s| s.as_str()).is_some());
973 assert!(v.get("endDate").and_then(|s| s.as_str()).is_some());
974 assert!(v.get("thresholds").and_then(|t| t.as_array()).is_some());
975 assert!(v.get("stats").is_some());
976 let first = &v["daily"][0];
978 assert!(first.get("date").is_some());
979 assert!(first.get("turns").is_some());
980 assert!(first.get("cost").is_some());
981 assert!(first.get("sessions").is_some());
982 let yesterday_str = yesterday.to_string();
984 let y_entry = v["daily"]
985 .as_array()
986 .unwrap()
987 .iter()
988 .find(|d| d["date"].as_str() == Some(&yesterday_str))
989 .expect("yesterday must be in the heatmap range");
990 assert_eq!(y_entry["turns"].as_u64(), Some(1));
991 let bd = &v["stats"]["busiestDay"];
993 assert_eq!(bd["date"].as_str(), Some(yesterday_str.as_str()));
994 assert_eq!(bd["turns"].as_u64(), Some(1));
995 }
996
997 #[test]
1000 fn html_report_payload_includes_heatmap_section() {
1001 use crate::analysis::overview::analyze_overview;
1002 use crate::analysis::project::analyze_projects;
1003 use crate::analysis::trend::analyze_trend;
1004 use crate::data::models::GlobalDataQuality;
1005
1006 let calc = PricingCalculator::new();
1007 let local_today = Local::now().date_naive();
1008 let ts: DateTime<Utc> = Local
1009 .from_local_datetime(&local_today.and_hms_opt(12, 0, 0).unwrap())
1010 .single()
1011 .unwrap()
1012 .with_timezone(&Utc);
1013 let sessions = vec![make_session("s1", vec![make_turn(&ts.to_rfc3339())])];
1014
1015 let overview = analyze_overview(&sessions, GlobalDataQuality::default(), &calc, None);
1016 let projects = analyze_projects(&sessions, &calc, 10);
1017 let trend = analyze_trend(&sessions, &calc, 0, false);
1018 let payload = render_html_payload(
1019 &overview,
1020 &projects,
1021 &trend,
1022 &sessions,
1023 &calc,
1024 None,
1025 None,
1026 std::path::Path::new("/nonexistent-claude-home"),
1027 );
1028 let v: serde_json::Value = serde_json::from_str(&payload).expect("must parse as JSON");
1029 let days = v["heatmap"]["days"]
1030 .as_array()
1031 .expect("heatmap.days must be an array");
1032 assert!(
1033 days.iter().any(|d| d["turns"].as_u64() == Some(1)),
1034 "the one turn we wrote must appear in heatmap.days"
1035 );
1036 }
1037}