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