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    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
113/// Build the typed `OverviewJson` struct from an `OverviewResult`.
114fn 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// ─── Session JSON ───────────────────────────────────────────────────────────
219
220#[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    // Tokens
230    output_tokens: u64,
231    context_tokens: u64,
232    cache_hit_rate: f64,
233    // Agents (aggregate roll-ups; per-subagent detail lives in `subagents`)
234    #[serde(rename = "agentTurnCount")]
235    agent_turn_count: u64,
236    agent_output_tokens: u64,
237    agent_cost: f64,
238    // Metadata
239    #[serde(skip_serializing_if = "Option::is_none")]
240    title: Option<String>,
241    #[serde(skip_serializing_if = "Vec::is_empty")]
242    tags: Vec<String>,
243    // Turn details (main session only)
244    turns: Vec<TurnJson>,
245    // Phase 2 capability inventory (always emitted as arrays, possibly empty).
246    // PluginUsage / SkillUsage / HookUsage serialize their inner fields as
247    // camelCase (matching the canonical Claude Code JSONL spelling).
248    subagents: Vec<SubagentJson>,
249    plugins: Vec<PluginUsage>,
250    skills: Vec<SkillUsage>,
251    hooks: Vec<HookUsage>,
252    /// Per-`agent_type` rollup of `subagents[]`. UI chips group by type.
253    /// SubagentTypeAggregate serializes to camelCase.
254    #[serde(rename = "subagentTypes")]
255    subagent_types: Vec<SubagentTypeAggregate>,
256    /// Workflow runs (`agent()` orchestrations, Claude Code 2.1.159+) for this
257    /// session. Always emitted (possibly empty). WorkflowSummary serializes to
258    /// camelCase. See `analysis::WorkflowSummary` for the field contract.
259    workflows: Vec<WorkflowSummary>,
260    /// Orphan session: scanner reconstructed this session from subagent
261    /// jsonl files only (parent jsonl deleted). Totals still include it.
262    #[serde(rename = "isOrphan")]
263    is_orphan: bool,
264}
265
266/// JSON shape for one subagent. Mirrors the spec's `subagents[]` schema
267/// (camelCase, no `agentTurns[]` flat alias — superseded by `agentTurnCount`
268/// scalar + `subagents[].turns[]` nested detail).
269#[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// ─── Projects JSON ──────────────────────────────────────────────────────────
372
373#[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
391/// Build the typed `ProjectsJson` struct from a `ProjectResult`.
392fn 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// ─── Trend JSON ─────────────────────────────────────────────────────────────
418
419#[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
436/// Build the typed `TrendJson` struct from a `TrendResult`.
437fn 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
468// ─── Wrapped JSON ──────────────────────────────────────────────────────────
469
470pub fn render_wrapped_json(result: &WrappedResult) -> String {
471    serde_json::to_string_pretty(result).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
472}
473
474// ─── Heatmap JSON ──────────────────────────────────────────────────────────
475
476#[derive(Serialize)]
477#[serde(rename_all = "camelCase")]
478struct HeatmapJson {
479    start_date: String,
480    end_date: String,
481    /// Percentile thresholds (P25, P50, P75) computed from non-zero days.
482    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    /// `None` when no day in the range has activity.
504    #[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// ─── Unified HTML Report Payload ───────────────────────────────────────────
546
547/// Unified JSON payload for the HTML dashboard.
548/// Combines data from all subcommands into a single structure.
549#[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/// Per-session summary for the HTML dashboard.
563#[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    // metadata
579    pub title: Option<String>,
580    #[serde(skip_serializing_if = "Vec::is_empty")]
581    pub tags: Vec<String>,
582    pub mode: Option<String>,
583    // Phase 2: session-level capability inventory. Always emitted as arrays
584    // (possibly empty) so the frontend type contract is stable.
585    pub subagents: Vec<HtmlSubagentSummary>,
586    pub plugins: Vec<PluginUsage>,
587    pub skills: Vec<SkillUsage>,
588    pub hooks: Vec<HookUsage>,
589    /// Per-`agent_type` rollup of `subagents[]` for chip rendering.
590    #[serde(rename = "subagentTypes")]
591    pub subagent_types: Vec<SubagentTypeAggregate>,
592    /// Workflow runs (`agent()` orchestrations, Claude Code 2.1.159+) for this
593    /// session. Always emitted (possibly empty). Each entry combines the run
594    /// snapshot (workflowName/status/durationMs/agentCount/totalTokens/phases)
595    /// with measured parsed totals (parsedAgentCount/parsedTurns/
596    /// parsedOutputTokens/parsedCost). WorkflowSummary serializes to camelCase.
597    pub workflows: Vec<WorkflowSummary>,
598    /// Orphan session: scanner reconstructed this session from subagent
599    /// jsonl files only (parent jsonl deleted). Totals still include it.
600    #[serde(rename = "isOrphan")]
601    pub is_orphan: bool,
602}
603
604/// Per-subagent summary for the HTML dashboard. Mirrors `SubagentJson` but
605/// lives under `HtmlSessionSummary` rather than the standalone `session`
606/// subcommand output.
607#[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/// Heatmap data for the HTML dashboard.
625#[derive(Serialize)]
626pub struct HeatmapPayload {
627    pub days: Vec<DailyActivity>,
628}
629
630/// A single day's aggregated activity metrics.
631#[derive(Serialize)]
632pub struct DailyActivity {
633    pub date: String,
634    pub turns: usize,
635    pub cost: f64,
636    pub sessions: usize,
637}
638
639/// Build the unified HTML report payload.
640///
641/// Reuses existing `render_*_json` functions for overview/projects/trend,
642/// then builds session summaries and heatmap data directly from `SessionData`.
643#[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    // Build typed structs and convert directly to serde_json::Value
655    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    // Build per-session summaries
663    let session_summaries: Vec<HtmlSessionSummary> = sessions
664        .iter()
665        .map(|s| build_html_session_summary(s, calc, claude_home))
666        .collect();
667
668    // Build heatmap by aggregating sessions per date
669    let heatmap = build_heatmap(sessions, calc);
670
671    // Build wrapped data if available
672    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
688/// Build an `HtmlSessionSummary` from a single `SessionData`.
689fn 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    // Compute total cost and cache hit rate
699    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
785/// Aggregate sessions by date to build heatmap data.
786///
787/// Each turn is attributed to its own local-time date (mirrors
788/// `analysis::heatmap::analyze_heatmap`). Earlier versions of this function
789/// dropped all of a multi-day session's turns onto its `first_timestamp`
790/// date, producing inflated single-day buckets for long sessions.
791fn build_heatmap(sessions: &[SessionData], calc: &PricingCalculator) -> HeatmapPayload {
792    // date -> (turns, cost, session_count)
793    let mut daily_map: HashMap<String, (usize, f64, usize)> = HashMap::new();
794
795    for session in sessions {
796        // Session is counted on the date of its `first_timestamp` (one
797        // session = one start). Turns and cost are attributed per-turn below.
798        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    // Sort by date ascending
834    days.sort_by(|a, b| a.date.cmp(&b.date));
835
836    HeatmapPayload { days }
837}
838
839// ─── Tests ───────────────────────────────────────────────────────────────────
840
841#[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    /// Bug regression: `build_heatmap` used to attribute *all* of a session's
905    /// turns to `first_timestamp.date`. A long-running session that spans two
906    /// local days must split its turns across those two days, not lump them
907    /// onto the start day.
908    #[test]
909    fn heatmap_html_payload_attributes_turns_per_day() {
910        let calc = PricingCalculator::new();
911        // Pick noon-local on two consecutive days so the test is timezone-independent.
912        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        // 12:00 local on each day, converted to UTC for the turn timestamp.
916        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        // Two distinct local dates -> two entries.
938        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        // The session is counted once on day_a (its first_timestamp date).
951        assert_eq!(entry_a.sessions, 1);
952        assert_eq!(entry_b.sessions, 0);
953    }
954
955    /// `render_heatmap_json` must produce a parseable JSON object with the
956    /// expected top-level keys (camelCase) and a `daily` array whose length
957    /// matches the heatmap range.
958    #[test]
959    fn heatmap_json_output_has_expected_shape() {
960        let calc = PricingCalculator::new();
961        // One turn at 12:00 local yesterday.
962        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        // Daily entries use camelCase too.
980        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        // Yesterday's bucket has exactly one turn.
986        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        // Busiest day matches.
995        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    /// The unified HTML report payload always includes a `heatmap` section
1001    /// with a `days` array (possibly empty).
1002    #[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}