Skip to main content

cc_token_usage/output/
json.rs

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// ─── Overview JSON ──────────────────────────────────────────────────────────
14
15#[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    // Efficiency
25    output_ratio: f64,
26    cost_per_turn: f64,
27    tokens_per_output_turn: u64,
28    // Cache savings
29    cache_savings: CacheSavingsJson,
30    // Subscription
31    subscription_value: Option<SubscriptionValueJson>,
32    // Cost breakdown
33    cost_by_category: CostByCategoryJson,
34    // Models
35    models: Vec<ModelJson>,
36    // Top tools
37    top_tools: Vec<ToolJson>,
38    // Sessions
39    sessions: Vec<SessionSummaryJson>,
40    /// Unknown-model pricing fallbacks. Empty array when every observed model
41    /// has explicit pricing — emitted as `[]` (never elided) so the frontend
42    /// type contract is stable.
43    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
111/// Build the typed `OverviewJson` struct from an `OverviewResult`.
112fn 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// ─── Session JSON ───────────────────────────────────────────────────────────
216
217#[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    // Tokens
227    output_tokens: u64,
228    context_tokens: u64,
229    cache_hit_rate: f64,
230    // Agents (aggregate roll-ups; per-subagent detail lives in `subagents`)
231    #[serde(rename = "agentTurnCount")]
232    agent_turn_count: u64,
233    agent_output_tokens: u64,
234    agent_cost: f64,
235    // Metadata
236    #[serde(skip_serializing_if = "Option::is_none")]
237    title: Option<String>,
238    #[serde(skip_serializing_if = "Vec::is_empty")]
239    tags: Vec<String>,
240    // Turn details (main session only)
241    turns: Vec<TurnJson>,
242    // Phase 2 capability inventory (always emitted as arrays, possibly empty).
243    // PluginUsage / SkillUsage / HookUsage serialize their inner fields as
244    // camelCase (matching the canonical Claude Code JSONL spelling).
245    subagents: Vec<SubagentJson>,
246    plugins: Vec<PluginUsage>,
247    skills: Vec<SkillUsage>,
248    hooks: Vec<HookUsage>,
249    /// Per-`agent_type` rollup of `subagents[]`. UI chips group by type.
250    /// SubagentTypeAggregate serializes to camelCase.
251    #[serde(rename = "subagentTypes")]
252    subagent_types: Vec<SubagentTypeAggregate>,
253    /// Workflow runs (`agent()` orchestrations, Claude Code 2.1.159+) for this
254    /// session. Always emitted (possibly empty). WorkflowSummary serializes to
255    /// camelCase. See `analysis::WorkflowSummary` for the field contract.
256    workflows: Vec<WorkflowSummary>,
257    /// Orphan session: scanner reconstructed this session from subagent
258    /// jsonl files only (parent jsonl deleted). Totals still include it.
259    #[serde(rename = "isOrphan")]
260    is_orphan: bool,
261}
262
263/// JSON shape for one subagent. Mirrors the spec's `subagents[]` schema
264/// (camelCase, no `agentTurns[]` flat alias — superseded by `agentTurnCount`
265/// scalar + `subagents[].turns[]` nested detail).
266#[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// ─── Projects JSON ──────────────────────────────────────────────────────────
369
370#[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
388/// Build the typed `ProjectsJson` struct from a `ProjectResult`.
389fn 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// ─── Trend JSON ─────────────────────────────────────────────────────────────
415
416#[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
433/// Build the typed `TrendJson` struct from a `TrendResult`.
434fn 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
465// ─── Wrapped JSON ──────────────────────────────────────────────────────────
466
467pub fn render_wrapped_json(result: &WrappedResult) -> String {
468    serde_json::to_string_pretty(result).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
469}
470
471// ─── Heatmap JSON ──────────────────────────────────────────────────────────
472
473#[derive(Serialize)]
474#[serde(rename_all = "camelCase")]
475struct HeatmapJson {
476    start_date: String,
477    end_date: String,
478    /// Percentile thresholds (P25, P50, P75) computed from non-zero days.
479    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    /// `None` when no day in the range has activity.
501    #[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// ─── Unified HTML Report Payload ───────────────────────────────────────────
543
544/// Unified JSON payload for the HTML dashboard.
545/// Combines data from all subcommands into a single structure.
546#[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/// Per-session summary for the HTML dashboard.
560#[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    // metadata
576    pub title: Option<String>,
577    #[serde(skip_serializing_if = "Vec::is_empty")]
578    pub tags: Vec<String>,
579    pub mode: Option<String>,
580    // Phase 2: session-level capability inventory. Always emitted as arrays
581    // (possibly empty) so the frontend type contract is stable.
582    pub subagents: Vec<HtmlSubagentSummary>,
583    pub plugins: Vec<PluginUsage>,
584    pub skills: Vec<SkillUsage>,
585    pub hooks: Vec<HookUsage>,
586    /// Per-`agent_type` rollup of `subagents[]` for chip rendering.
587    #[serde(rename = "subagentTypes")]
588    pub subagent_types: Vec<SubagentTypeAggregate>,
589    /// Workflow runs (`agent()` orchestrations, Claude Code 2.1.159+) for this
590    /// session. Always emitted (possibly empty). Each entry combines the run
591    /// snapshot (workflowName/status/durationMs/agentCount/totalTokens/phases)
592    /// with measured parsed totals (parsedAgentCount/parsedTurns/
593    /// parsedOutputTokens/parsedCost). WorkflowSummary serializes to camelCase.
594    pub workflows: Vec<WorkflowSummary>,
595    /// Orphan session: scanner reconstructed this session from subagent
596    /// jsonl files only (parent jsonl deleted). Totals still include it.
597    #[serde(rename = "isOrphan")]
598    pub is_orphan: bool,
599}
600
601/// Per-subagent summary for the HTML dashboard. Mirrors `SubagentJson` but
602/// lives under `HtmlSessionSummary` rather than the standalone `session`
603/// subcommand output.
604#[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/// Heatmap data for the HTML dashboard.
622#[derive(Serialize)]
623pub struct HeatmapPayload {
624    pub days: Vec<DailyActivity>,
625}
626
627/// A single day's aggregated activity metrics.
628#[derive(Serialize)]
629pub struct DailyActivity {
630    pub date: String,
631    pub turns: usize,
632    pub cost: f64,
633    pub sessions: usize,
634}
635
636/// Build the unified HTML report payload.
637///
638/// Reuses existing `render_*_json` functions for overview/projects/trend,
639/// then builds session summaries and heatmap data directly from `SessionData`.
640#[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    // Build typed structs and convert directly to serde_json::Value
652    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    // Build per-session summaries
660    let session_summaries: Vec<HtmlSessionSummary> = sessions
661        .iter()
662        .map(|s| build_html_session_summary(s, calc, claude_home))
663        .collect();
664
665    // Build heatmap by aggregating sessions per date
666    let heatmap = build_heatmap(sessions, calc);
667
668    // Build wrapped data if available
669    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
685/// Build an `HtmlSessionSummary` from a single `SessionData`.
686fn 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    // Compute total cost and cache hit rate
696    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
782/// Aggregate sessions by date to build heatmap data.
783///
784/// Each turn is attributed to its own local-time date (mirrors
785/// `analysis::heatmap::analyze_heatmap`). Earlier versions of this function
786/// dropped all of a multi-day session's turns onto its `first_timestamp`
787/// date, producing inflated single-day buckets for long sessions.
788fn build_heatmap(sessions: &[SessionData], calc: &PricingCalculator) -> HeatmapPayload {
789    // date -> (turns, cost, session_count)
790    let mut daily_map: HashMap<String, (usize, f64, usize)> = HashMap::new();
791
792    for session in sessions {
793        // Session is counted on the date of its `first_timestamp` (one
794        // session = one start). Turns and cost are attributed per-turn below.
795        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    // Sort by date ascending
831    days.sort_by(|a, b| a.date.cmp(&b.date));
832
833    HeatmapPayload { days }
834}
835
836// ─── Tests ───────────────────────────────────────────────────────────────────
837
838#[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    /// Bug regression: `build_heatmap` used to attribute *all* of a session's
902    /// turns to `first_timestamp.date`. A long-running session that spans two
903    /// local days must split its turns across those two days, not lump them
904    /// onto the start day.
905    #[test]
906    fn heatmap_html_payload_attributes_turns_per_day() {
907        let calc = PricingCalculator::new();
908        // Pick noon-local on two consecutive days so the test is timezone-independent.
909        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        // 12:00 local on each day, converted to UTC for the turn timestamp.
913        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        // Two distinct local dates -> two entries.
935        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        // The session is counted once on day_a (its first_timestamp date).
948        assert_eq!(entry_a.sessions, 1);
949        assert_eq!(entry_b.sessions, 0);
950    }
951
952    /// `render_heatmap_json` must produce a parseable JSON object with the
953    /// expected top-level keys (camelCase) and a `daily` array whose length
954    /// matches the heatmap range.
955    #[test]
956    fn heatmap_json_output_has_expected_shape() {
957        let calc = PricingCalculator::new();
958        // One turn at 12:00 local yesterday.
959        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        // Daily entries use camelCase too.
977        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        // Yesterday's bucket has exactly one turn.
983        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        // Busiest day matches.
992        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    /// The unified HTML report payload always includes a `heatmap` section
998    /// with a `days` array (possibly empty).
999    #[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}