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};
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    /// Orphan session: scanner reconstructed this session from subagent
254    /// jsonl files only (parent jsonl deleted). Totals still include it.
255    #[serde(rename = "isOrphan")]
256    is_orphan: bool,
257}
258
259/// JSON shape for one subagent. Mirrors the spec's `subagents[]` schema
260/// (camelCase, no `agentTurns[]` flat alias — superseded by `agentTurnCount`
261/// scalar + `subagents[].turns[]` nested detail).
262#[derive(Serialize)]
263#[serde(rename_all = "camelCase")]
264struct SubagentJson {
265    agent_id: String,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    agent_type: Option<String>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    description: Option<String>,
270    turns: usize,
271    output_tokens: u64,
272    cost: f64,
273}
274
275#[derive(Serialize)]
276struct TurnJson {
277    turn_number: usize,
278    timestamp: String,
279    model: String,
280    input_tokens: u64,
281    output_tokens: u64,
282    cache_read_tokens: u64,
283    context_size: u64,
284    cache_hit_rate: f64,
285    cost: f64,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    stop_reason: Option<String>,
288    is_agent: bool,
289    is_compaction: bool,
290    #[serde(skip_serializing_if = "Vec::is_empty")]
291    tool_names: Vec<String>,
292}
293
294pub fn render_session_json(result: &SessionResult) -> String {
295    let ctx = result.total_tokens.context_tokens();
296    let cache_hit_rate = if ctx > 0 {
297        result.total_tokens.cache_read_tokens as f64 / ctx as f64 * 100.0
298    } else {
299        0.0
300    };
301
302    let turns: Vec<TurnJson> = result
303        .turn_details
304        .iter()
305        .map(|t| TurnJson {
306            turn_number: t.turn_number,
307            timestamp: t.timestamp.to_rfc3339(),
308            model: t.model.clone(),
309            input_tokens: t.input_tokens,
310            output_tokens: t.output_tokens,
311            cache_read_tokens: t.cache_read_tokens,
312            context_size: t.context_size,
313            cache_hit_rate: t.cache_hit_rate,
314            cost: t.cost,
315            stop_reason: t.stop_reason.clone(),
316            is_agent: t.is_agent,
317            is_compaction: t.is_compaction,
318            tool_names: t.tool_names.clone(),
319        })
320        .collect();
321
322    let subagents: Vec<SubagentJson> = result
323        .subagents
324        .iter()
325        .map(|s| SubagentJson {
326            agent_id: s.agent_id.clone(),
327            agent_type: s.agent_type.clone(),
328            description: s.description.clone(),
329            turns: s.turns,
330            output_tokens: s.output_tokens,
331            cost: s.cost,
332        })
333        .collect();
334
335    let json = SessionJson {
336        session_id: result.session_id.clone(),
337        project: result.project.clone(),
338        model: result.model.clone(),
339        duration_minutes: result.duration_minutes,
340        total_cost: result.total_cost,
341        max_context: result.max_context,
342        compaction_count: result.compaction_count,
343        output_tokens: result.total_tokens.output_tokens,
344        context_tokens: ctx,
345        cache_hit_rate,
346        agent_turn_count: result.agent_summary.total_agent_turns as u64,
347        agent_output_tokens: result.agent_summary.agent_output_tokens,
348        agent_cost: result.agent_summary.agent_cost,
349        title: result.title.clone(),
350        tags: result.tags.clone(),
351        turns,
352        subagents,
353        plugins: result.plugins.clone(),
354        skills: result.skills.clone(),
355        hooks: result.hooks.clone(),
356        subagent_types: result.subagent_types.clone(),
357        is_orphan: result.is_orphan,
358    };
359
360    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
361}
362
363// ─── Projects JSON ──────────────────────────────────────────────────────────
364
365#[derive(Serialize)]
366struct ProjectsJson {
367    projects: Vec<ProjectJson>,
368}
369
370#[derive(Serialize)]
371struct ProjectJson {
372    name: String,
373    display_name: String,
374    session_count: usize,
375    total_turns: usize,
376    agent_turns: usize,
377    output_tokens: u64,
378    context_tokens: u64,
379    cost: f64,
380    primary_model: String,
381}
382
383/// Build the typed `ProjectsJson` struct from a `ProjectResult`.
384fn build_projects_json(projects: &ProjectResult) -> ProjectsJson {
385    ProjectsJson {
386        projects: projects
387            .projects
388            .iter()
389            .map(|p| ProjectJson {
390                name: p.name.clone(),
391                display_name: p.display_name.clone(),
392                session_count: p.session_count,
393                total_turns: p.total_turns,
394                agent_turns: p.agent_turns,
395                output_tokens: p.tokens.output_tokens,
396                context_tokens: p.tokens.context_tokens(),
397                cost: p.cost,
398                primary_model: p.primary_model.clone(),
399            })
400            .collect(),
401    }
402}
403
404pub fn render_projects_json(projects: &ProjectResult) -> String {
405    let json = build_projects_json(projects);
406    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
407}
408
409// ─── Trend JSON ─────────────────────────────────────────────────────────────
410
411#[derive(Serialize)]
412struct TrendJson {
413    group_label: String,
414    entries: Vec<TrendEntryJson>,
415}
416
417#[derive(Serialize)]
418struct TrendEntryJson {
419    label: String,
420    session_count: usize,
421    turn_count: usize,
422    output_tokens: u64,
423    context_tokens: u64,
424    cost: f64,
425    cost_per_turn: f64,
426}
427
428/// Build the typed `TrendJson` struct from a `TrendResult`.
429fn build_trend_json(trend: &TrendResult) -> TrendJson {
430    TrendJson {
431        group_label: trend.group_label.clone(),
432        entries: trend
433            .entries
434            .iter()
435            .map(|e| {
436                let cpt = if e.turn_count > 0 {
437                    e.cost / e.turn_count as f64
438                } else {
439                    0.0
440                };
441                TrendEntryJson {
442                    label: e.label.clone(),
443                    session_count: e.session_count,
444                    turn_count: e.turn_count,
445                    output_tokens: e.tokens.output_tokens,
446                    context_tokens: e.tokens.context_tokens(),
447                    cost: e.cost,
448                    cost_per_turn: cpt,
449                }
450            })
451            .collect(),
452    }
453}
454
455pub fn render_trend_json(trend: &TrendResult) -> String {
456    let json = build_trend_json(trend);
457    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
458}
459
460// ─── Wrapped JSON ──────────────────────────────────────────────────────────
461
462pub fn render_wrapped_json(result: &WrappedResult) -> String {
463    serde_json::to_string_pretty(result).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
464}
465
466// ─── Heatmap JSON ──────────────────────────────────────────────────────────
467
468#[derive(Serialize)]
469#[serde(rename_all = "camelCase")]
470struct HeatmapJson {
471    start_date: String,
472    end_date: String,
473    /// Percentile thresholds (P25, P50, P75) computed from non-zero days.
474    thresholds: [usize; 3],
475    daily: Vec<DailyActivityJson>,
476    stats: HeatmapStatsJson,
477}
478
479#[derive(Serialize)]
480#[serde(rename_all = "camelCase")]
481struct DailyActivityJson {
482    date: String,
483    turns: usize,
484    cost: f64,
485    sessions: usize,
486}
487
488#[derive(Serialize)]
489#[serde(rename_all = "camelCase")]
490struct HeatmapStatsJson {
491    total_days: usize,
492    active_days: usize,
493    current_streak: usize,
494    longest_streak: usize,
495    /// `None` when no day in the range has activity.
496    #[serde(skip_serializing_if = "Option::is_none")]
497    busiest_day: Option<BusiestDayJson>,
498}
499
500#[derive(Serialize)]
501#[serde(rename_all = "camelCase")]
502struct BusiestDayJson {
503    date: String,
504    turns: usize,
505}
506
507pub fn render_heatmap_json(result: &HeatmapResult) -> String {
508    let (p25, p50, p75) = result.thresholds;
509    let json = HeatmapJson {
510        start_date: result.start_date.to_string(),
511        end_date: result.end_date.to_string(),
512        thresholds: [p25, p50, p75],
513        daily: result
514            .daily
515            .iter()
516            .map(|d| DailyActivityJson {
517                date: d.date.to_string(),
518                turns: d.turns,
519                cost: d.cost,
520                sessions: d.sessions,
521            })
522            .collect(),
523        stats: HeatmapStatsJson {
524            total_days: result.stats.total_days,
525            active_days: result.stats.active_days,
526            current_streak: result.stats.current_streak,
527            longest_streak: result.stats.longest_streak,
528            busiest_day: result.stats.busiest_day.map(|(d, n)| BusiestDayJson {
529                date: d.to_string(),
530                turns: n,
531            }),
532        },
533    };
534    serde_json::to_string_pretty(&json).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
535}
536
537// ─── Unified HTML Report Payload ───────────────────────────────────────────
538
539/// Unified JSON payload for the HTML dashboard.
540/// Combines data from all subcommands into a single structure.
541#[derive(Serialize)]
542pub struct HtmlReportPayload {
543    pub overview: serde_json::Value,
544    pub projects: serde_json::Value,
545    pub trends: serde_json::Value,
546    pub sessions: Vec<HtmlSessionSummary>,
547    pub heatmap: HeatmapPayload,
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub wrapped: Option<serde_json::Value>,
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub active_session_id: Option<String>,
552}
553
554/// Per-session summary for the HTML dashboard.
555#[derive(Serialize)]
556pub struct HtmlSessionSummary {
557    pub id: String,
558    pub project: Option<String>,
559    pub turns: usize,
560    #[serde(rename = "agentTurnCount")]
561    pub agent_turn_count: u64,
562    pub cost: f64,
563    pub duration_minutes: Option<f64>,
564    pub model: Option<String>,
565    pub cache_hit_rate: Option<f64>,
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub first_timestamp: Option<String>,
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub last_timestamp: Option<String>,
570    // metadata
571    pub title: Option<String>,
572    #[serde(skip_serializing_if = "Vec::is_empty")]
573    pub tags: Vec<String>,
574    pub mode: Option<String>,
575    // Phase 2: session-level capability inventory. Always emitted as arrays
576    // (possibly empty) so the frontend type contract is stable.
577    pub subagents: Vec<HtmlSubagentSummary>,
578    pub plugins: Vec<PluginUsage>,
579    pub skills: Vec<SkillUsage>,
580    pub hooks: Vec<HookUsage>,
581    /// Per-`agent_type` rollup of `subagents[]` for chip rendering.
582    #[serde(rename = "subagentTypes")]
583    pub subagent_types: Vec<SubagentTypeAggregate>,
584    /// Orphan session: scanner reconstructed this session from subagent
585    /// jsonl files only (parent jsonl deleted). Totals still include it.
586    #[serde(rename = "isOrphan")]
587    pub is_orphan: bool,
588}
589
590/// Per-subagent summary for the HTML dashboard. Mirrors `SubagentJson` but
591/// lives under `HtmlSessionSummary` rather than the standalone `session`
592/// subcommand output.
593#[derive(Serialize)]
594#[serde(rename_all = "camelCase")]
595pub struct HtmlSubagentSummary {
596    pub agent_id: String,
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub agent_type: Option<String>,
599    #[serde(skip_serializing_if = "Option::is_none")]
600    pub description: Option<String>,
601    pub turns: usize,
602    pub output_tokens: u64,
603    pub cost: f64,
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub first_timestamp: Option<String>,
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub last_timestamp: Option<String>,
608}
609
610/// Heatmap data for the HTML dashboard.
611#[derive(Serialize)]
612pub struct HeatmapPayload {
613    pub days: Vec<DailyActivity>,
614}
615
616/// A single day's aggregated activity metrics.
617#[derive(Serialize)]
618pub struct DailyActivity {
619    pub date: String,
620    pub turns: usize,
621    pub cost: f64,
622    pub sessions: usize,
623}
624
625/// Build the unified HTML report payload.
626///
627/// Reuses existing `render_*_json` functions for overview/projects/trend,
628/// then builds session summaries and heatmap data directly from `SessionData`.
629pub fn render_html_payload(
630    overview: &OverviewResult,
631    projects: &ProjectResult,
632    trend: &TrendResult,
633    sessions: &[SessionData],
634    calc: &PricingCalculator,
635    wrapped: Option<&WrappedResult>,
636    active_session_id: Option<&str>,
637) -> String {
638    // Build typed structs and convert directly to serde_json::Value
639    let overview_json: serde_json::Value =
640        serde_json::to_value(build_overview_json(overview)).unwrap_or(serde_json::Value::Null);
641    let projects_json: serde_json::Value =
642        serde_json::to_value(build_projects_json(projects)).unwrap_or(serde_json::Value::Null);
643    let trends_json: serde_json::Value =
644        serde_json::to_value(build_trend_json(trend)).unwrap_or(serde_json::Value::Null);
645
646    // Build per-session summaries
647    let session_summaries: Vec<HtmlSessionSummary> = sessions
648        .iter()
649        .map(|s| build_html_session_summary(s, calc))
650        .collect();
651
652    // Build heatmap by aggregating sessions per date
653    let heatmap = build_heatmap(sessions, calc);
654
655    // Build wrapped data if available
656    let wrapped_json: Option<serde_json::Value> =
657        wrapped.and_then(|w| serde_json::to_value(w).ok());
658
659    let payload = HtmlReportPayload {
660        overview: overview_json,
661        projects: projects_json,
662        trends: trends_json,
663        sessions: session_summaries,
664        heatmap,
665        wrapped: wrapped_json,
666        active_session_id: active_session_id.map(|s| s.to_string()),
667    };
668
669    serde_json::to_string(&payload).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
670}
671
672/// Build an `HtmlSessionSummary` from a single `SessionData`.
673fn build_html_session_summary(
674    session: &SessionData,
675    calc: &PricingCalculator,
676) -> HtmlSessionSummary {
677    let all = session.all_responses();
678    let turn_count = all.len();
679    let agent_turn_count = session.agent_turn_count();
680
681    // Compute total cost and cache hit rate
682    let mut total_cost = 0.0;
683    let mut total_cache_read: u64 = 0;
684    let mut total_context: u64 = 0;
685    let mut model_counts: HashMap<&str, usize> = HashMap::new();
686
687    for turn in &all {
688        let cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
689        total_cost += cost.total;
690
691        let input = turn.usage.input_tokens.unwrap_or(0);
692        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
693        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
694        let ctx = input + cache_create + cache_read;
695
696        total_context += ctx;
697        total_cache_read += cache_read;
698
699        *model_counts.entry(&turn.model).or_insert(0) += 1;
700    }
701
702    let cache_hit_rate = if total_context > 0 {
703        Some((total_cache_read as f64 / total_context as f64) * 100.0)
704    } else {
705        None
706    };
707
708    let primary_model = model_counts
709        .into_iter()
710        .max_by_key(|(_, count)| *count)
711        .map(|(m, _)| m.to_string());
712
713    let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
714        (Some(first), Some(last)) => Some((last - first).num_seconds() as f64 / 60.0),
715        _ => None,
716    };
717
718    let subagents: Vec<HtmlSubagentSummary> = session
719        .subagents
720        .iter()
721        .map(|sa| {
722            let mut output_tokens: u64 = 0;
723            let mut sa_cost = 0.0f64;
724            for t in &sa.turns {
725                output_tokens += t.usage.output_tokens.unwrap_or(0);
726                sa_cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
727            }
728            HtmlSubagentSummary {
729                agent_id: sa.agent_id.clone(),
730                agent_type: sa.agent_type.clone(),
731                description: sa.description.clone(),
732                turns: sa.turns.len(),
733                output_tokens,
734                cost: sa_cost,
735                first_timestamp: sa.first_timestamp.map(|t| t.to_rfc3339()),
736                last_timestamp: sa.last_timestamp.map(|t| t.to_rfc3339()),
737            }
738        })
739        .collect();
740
741    let subagent_types = session.subagent_type_aggregates(calc);
742
743    HtmlSessionSummary {
744        id: session.session_id.clone(),
745        project: session.project.as_deref().map(project_display_name),
746        turns: turn_count,
747        agent_turn_count: agent_turn_count as u64,
748        cost: total_cost,
749        duration_minutes,
750        model: primary_model,
751        cache_hit_rate,
752        first_timestamp: session.first_timestamp.map(|t| t.to_rfc3339()),
753        last_timestamp: session.last_timestamp.map(|t| t.to_rfc3339()),
754        title: session.metadata.title.clone(),
755        tags: session.metadata.tags.clone(),
756        mode: session.metadata.mode.clone(),
757        subagents,
758        plugins: session.plugins.clone(),
759        skills: session.skills.clone(),
760        hooks: session.hooks.clone(),
761        subagent_types,
762        is_orphan: session.is_orphan,
763    }
764}
765
766/// Aggregate sessions by date to build heatmap data.
767///
768/// Each turn is attributed to its own local-time date (mirrors
769/// `analysis::heatmap::analyze_heatmap`). Earlier versions of this function
770/// dropped all of a multi-day session's turns onto its `first_timestamp`
771/// date, producing inflated single-day buckets for long sessions.
772fn build_heatmap(sessions: &[SessionData], calc: &PricingCalculator) -> HeatmapPayload {
773    // date -> (turns, cost, session_count)
774    let mut daily_map: HashMap<String, (usize, f64, usize)> = HashMap::new();
775
776    for session in sessions {
777        // Session is counted on the date of its `first_timestamp` (one
778        // session = one start). Turns and cost are attributed per-turn below.
779        if let Some(ts) = session.first_timestamp {
780            let local = ts.with_timezone(&chrono::Local);
781            let date_key = format!(
782                "{:04}-{:02}-{:02}",
783                local.year(),
784                local.month(),
785                local.day()
786            );
787            daily_map.entry(date_key).or_insert((0, 0.0, 0)).2 += 1;
788        }
789
790        for turn in session.all_responses() {
791            let local = turn.timestamp.with_timezone(&chrono::Local);
792            let date_key = format!(
793                "{:04}-{:02}-{:02}",
794                local.year(),
795                local.month(),
796                local.day()
797            );
798            let entry = daily_map.entry(date_key).or_insert((0, 0.0, 0));
799            entry.0 += 1;
800            entry.1 += calc.calculate_turn_cost(&turn.model, &turn.usage).total;
801        }
802    }
803
804    let mut days: Vec<DailyActivity> = daily_map
805        .into_iter()
806        .map(|(date, (turns, cost, session_count))| DailyActivity {
807            date,
808            turns,
809            cost,
810            sessions: session_count,
811        })
812        .collect();
813
814    // Sort by date ascending
815    days.sort_by(|a, b| a.date.cmp(&b.date));
816
817    HeatmapPayload { days }
818}
819
820// ─── Tests ───────────────────────────────────────────────────────────────────
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use crate::analysis::heatmap::analyze_heatmap;
826    use crate::data::models::{
827        DataQuality, SessionData, SessionMetadata, TokenUsage, ValidatedTurn,
828    };
829    use chrono::{DateTime, Local, TimeZone, Utc};
830
831    fn make_turn(ts: &str) -> ValidatedTurn {
832        ValidatedTurn {
833            uuid: format!("u-{ts}"),
834            request_id: Some(format!("r-{ts}")),
835            timestamp: ts.parse::<DateTime<Utc>>().unwrap(),
836            model: "claude-sonnet-4-20250514".into(),
837            usage: TokenUsage {
838                input_tokens: Some(10),
839                output_tokens: Some(20),
840                cache_creation_input_tokens: Some(0),
841                cache_read_input_tokens: Some(0),
842                cache_creation: None,
843                server_tool_use: None,
844                service_tier: None,
845                speed: None,
846                inference_geo: None,
847            },
848            stop_reason: None,
849            content_types: vec!["text".into()],
850            is_agent: false,
851            agent_id: None,
852            user_text: None,
853            assistant_text: None,
854            tool_names: vec![],
855            service_tier: None,
856            speed: None,
857            inference_geo: None,
858            tool_error_count: 0,
859            git_branch: None,
860            attribution_plugin: None,
861            attribution_skill: None,
862        }
863    }
864
865    fn make_session(id: &str, turns: Vec<ValidatedTurn>) -> SessionData {
866        let first = turns.iter().map(|t| t.timestamp).min();
867        let last = turns.iter().map(|t| t.timestamp).max();
868        SessionData {
869            session_id: id.into(),
870            project: Some("test".into()),
871            turns,
872            subagents: vec![],
873            plugins: vec![],
874            skills: vec![],
875            hooks: vec![],
876            first_timestamp: first,
877            last_timestamp: last,
878            version: None,
879            quality: DataQuality::default(),
880            metadata: SessionMetadata::default(),
881            is_orphan: false,
882        }
883    }
884
885    /// Bug regression: `build_heatmap` used to attribute *all* of a session's
886    /// turns to `first_timestamp.date`. A long-running session that spans two
887    /// local days must split its turns across those two days, not lump them
888    /// onto the start day.
889    #[test]
890    fn heatmap_html_payload_attributes_turns_per_day() {
891        let calc = PricingCalculator::new();
892        // Pick noon-local on two consecutive days so the test is timezone-independent.
893        let local_today = Local::now().date_naive();
894        let day_a = local_today - chrono::Duration::days(2);
895        let day_b = local_today - chrono::Duration::days(1);
896        // 12:00 local on each day, converted to UTC for the turn timestamp.
897        let ts_a: DateTime<Utc> = Local
898            .from_local_datetime(&day_a.and_hms_opt(12, 0, 0).unwrap())
899            .single()
900            .unwrap()
901            .with_timezone(&Utc);
902        let ts_b: DateTime<Utc> = Local
903            .from_local_datetime(&day_b.and_hms_opt(12, 0, 0).unwrap())
904            .single()
905            .unwrap()
906            .with_timezone(&Utc);
907
908        let sessions = vec![make_session(
909            "s1",
910            vec![
911                make_turn(&ts_a.to_rfc3339()),
912                make_turn(&ts_a.to_rfc3339()),
913                make_turn(&ts_b.to_rfc3339()),
914            ],
915        )];
916
917        let hm = build_heatmap(&sessions, &calc);
918        // Two distinct local dates -> two entries.
919        let day_a_str = day_a.to_string();
920        let day_b_str = day_b.to_string();
921        let entry_a = hm.days.iter().find(|d| d.date == day_a_str).unwrap();
922        let entry_b = hm.days.iter().find(|d| d.date == day_b_str).unwrap();
923        assert_eq!(
924            entry_a.turns, 2,
925            "two turns at 12:00 local on day_a must stay on day_a"
926        );
927        assert_eq!(
928            entry_b.turns, 1,
929            "one turn at 12:00 local on day_b must be attributed to day_b"
930        );
931        // The session is counted once on day_a (its first_timestamp date).
932        assert_eq!(entry_a.sessions, 1);
933        assert_eq!(entry_b.sessions, 0);
934    }
935
936    /// `render_heatmap_json` must produce a parseable JSON object with the
937    /// expected top-level keys (camelCase) and a `daily` array whose length
938    /// matches the heatmap range.
939    #[test]
940    fn heatmap_json_output_has_expected_shape() {
941        let calc = PricingCalculator::new();
942        // One turn at 12:00 local yesterday.
943        let local_today = Local::now().date_naive();
944        let yesterday = local_today - chrono::Duration::days(1);
945        let ts: DateTime<Utc> = Local
946            .from_local_datetime(&yesterday.and_hms_opt(12, 0, 0).unwrap())
947            .single()
948            .unwrap()
949            .with_timezone(&Utc);
950        let sessions = vec![make_session("s1", vec![make_turn(&ts.to_rfc3339())])];
951
952        let result = analyze_heatmap(&sessions, &calc, 7);
953        let json_str = render_heatmap_json(&result);
954        let v: serde_json::Value = serde_json::from_str(&json_str).expect("must parse as JSON");
955        assert!(v.get("daily").and_then(|d| d.as_array()).is_some());
956        assert!(v.get("startDate").and_then(|s| s.as_str()).is_some());
957        assert!(v.get("endDate").and_then(|s| s.as_str()).is_some());
958        assert!(v.get("thresholds").and_then(|t| t.as_array()).is_some());
959        assert!(v.get("stats").is_some());
960        // Daily entries use camelCase too.
961        let first = &v["daily"][0];
962        assert!(first.get("date").is_some());
963        assert!(first.get("turns").is_some());
964        assert!(first.get("cost").is_some());
965        assert!(first.get("sessions").is_some());
966        // Yesterday's bucket has exactly one turn.
967        let yesterday_str = yesterday.to_string();
968        let y_entry = v["daily"]
969            .as_array()
970            .unwrap()
971            .iter()
972            .find(|d| d["date"].as_str() == Some(&yesterday_str))
973            .expect("yesterday must be in the heatmap range");
974        assert_eq!(y_entry["turns"].as_u64(), Some(1));
975        // Busiest day matches.
976        let bd = &v["stats"]["busiestDay"];
977        assert_eq!(bd["date"].as_str(), Some(yesterday_str.as_str()));
978        assert_eq!(bd["turns"].as_u64(), Some(1));
979    }
980
981    /// The unified HTML report payload always includes a `heatmap` section
982    /// with a `days` array (possibly empty).
983    #[test]
984    fn html_report_payload_includes_heatmap_section() {
985        use crate::analysis::overview::analyze_overview;
986        use crate::analysis::project::analyze_projects;
987        use crate::analysis::trend::analyze_trend;
988        use crate::data::models::GlobalDataQuality;
989
990        let calc = PricingCalculator::new();
991        let local_today = Local::now().date_naive();
992        let ts: DateTime<Utc> = Local
993            .from_local_datetime(&local_today.and_hms_opt(12, 0, 0).unwrap())
994            .single()
995            .unwrap()
996            .with_timezone(&Utc);
997        let sessions = vec![make_session("s1", vec![make_turn(&ts.to_rfc3339())])];
998
999        let overview = analyze_overview(&sessions, GlobalDataQuality::default(), &calc, None);
1000        let projects = analyze_projects(&sessions, &calc, 10);
1001        let trend = analyze_trend(&sessions, &calc, 0, false);
1002        let payload =
1003            render_html_payload(&overview, &projects, &trend, &sessions, &calc, None, None);
1004        let v: serde_json::Value = serde_json::from_str(&payload).expect("must parse as JSON");
1005        let days = v["heatmap"]["days"]
1006            .as_array()
1007            .expect("heatmap.days must be an array");
1008        assert!(
1009            days.iter().any(|d| d["turns"].as_u64() == Some(1)),
1010            "the one turn we wrote must appear in heatmap.days"
1011        );
1012    }
1013}