@@ -17,7 +17,7 @@ use std::collections::HashMap;
1717use std:: time:: { Instant , SystemTime } ;
1818
1919use anyhow:: Result ;
20- use chrono:: Local ;
20+ use chrono:: { Local , TimeZone } ;
2121use dashmap:: DashSet ;
2222use rayon:: prelude:: * ;
2323
@@ -307,13 +307,14 @@ fn build_session_summary(
307307 total_cache_write += call. usage . cache_creation_input_tokens ;
308308 api_calls += 1 ;
309309
310- // Accumulate daily costs
310+ // Accumulate daily costs and breakdowns (bucket by local date to
311+ // match the JS version's `dateKey` which uses local time).
311312 if call. timestamp . len ( ) >= 10 {
312- let day = & call. timestamp [ .. 10 ] ;
313+ let day = utc_ts_to_local_day ( & call. timestamp ) ;
313314 let daily = daily_map
314- . entry ( day. to_string ( ) )
315+ . entry ( day. clone ( ) )
315316 . or_insert_with ( || crate :: types:: DailyCostEntry {
316- day : day . to_string ( ) ,
317+ day,
317318 ..Default :: default ( )
318319 } ) ;
319320 daily. cost_usd += call. cost_usd ;
@@ -322,6 +323,19 @@ fn build_session_summary(
322323 daily. output_tokens += call. usage . output_tokens ;
323324 daily. cache_read_tokens += call. usage . cache_read_input_tokens ;
324325 daily. cache_write_tokens += call. usage . cache_creation_input_tokens ;
326+
327+ for tool in call. tools . iter ( ) . filter ( |t| !t. starts_with ( "mcp__" ) ) {
328+ * daily. tool_breakdown . entry ( tool. clone ( ) ) . or_default ( ) += 1 ;
329+ }
330+ for mcp in & call. mcp_tools {
331+ let server = mcp. split ( "__" ) . nth ( 1 ) . unwrap_or ( mcp) ;
332+ * daily. mcp_breakdown . entry ( server. to_string ( ) ) . or_default ( ) += 1 ;
333+ }
334+ for cmd in & call. bash_commands {
335+ * daily. bash_breakdown . entry ( cmd. clone ( ) ) . or_default ( ) += 1 ;
336+ }
337+ let model_key = get_short_model_name ( & call. model ) ;
338+ * daily. model_breakdown . entry ( model_key. clone ( ) ) . or_default ( ) += 1 ;
325339 }
326340
327341 let model_key = get_short_model_name ( & call. model ) ;
@@ -663,13 +677,7 @@ fn build_source_summaries(
663677/// Trim a cached `SessionSummary` down to the calls that fall within
664678/// `date_range`. The returned summary has:
665679/// - `daily_costs` restricted to in-range UTC days.
666- /// - totals derived from those filtered days (cost / tokens / call count).
667- /// Session-level breakdowns (`model_breakdown`, `tool_breakdown`,
668- /// `bash_breakdown`, `mcp_breakdown`, `category_breakdown`) are carried
669- /// through unchanged — for sessions that straddle the period boundary this
670- /// can include contributions from out-of-range calls. In practice sessions
671- /// are short-lived so the overlap is tiny; the dashboard totals (which come
672- /// from the filtered daily_costs) stay exact.
680+ /// - totals and breakdowns rebuilt from those filtered days.
673681///
674682/// Returns `None` when no day of the session falls in range. Consumes `s`
675683/// so the HashMap breakdowns can be moved (not cloned) into the output.
@@ -678,15 +686,20 @@ fn filter_session_for_range(
678686 start_day : & str ,
679687 end_day : & str ,
680688) -> Option < SessionSummary > {
681- // Quick path: whole session is outside the range. `first_timestamp` /
682- // `last_timestamp` are UTC 19-char prefixes; start/end are UTC "YYYY-MM-DD"
683- // days extracted from the caller's range.
684- if !s. last_timestamp . is_empty ( ) && s. last_timestamp . as_str ( ) < start_day {
685- return None ;
689+ // Quick path: whole session is outside the range. Convert the UTC
690+ // timestamps to local day strings so the comparison is consistent with
691+ // the local-date daily bucketing.
692+ if !s. last_timestamp . is_empty ( ) {
693+ let last_day = utc_ts_to_local_day ( & s. last_timestamp ) ;
694+ if last_day. as_str ( ) < start_day {
695+ return None ;
696+ }
686697 }
687- let end_upper = end_day_upper_bound ( end_day) ;
688- if !s. first_timestamp . is_empty ( ) && s. first_timestamp . as_str ( ) > end_upper. as_str ( ) {
689- return None ;
698+ if !s. first_timestamp . is_empty ( ) {
699+ let first_day = utc_ts_to_local_day ( & s. first_timestamp ) ;
700+ if first_day. as_str ( ) > end_day {
701+ return None ;
702+ }
690703 }
691704
692705 // Retain in-range days in place; bail if none match so we skip the move
@@ -704,13 +717,34 @@ fn filter_session_for_range(
704717 let mut total_output = 0u64 ;
705718 let mut total_cache_read = 0u64 ;
706719 let mut total_cache_write = 0u64 ;
720+
721+ // Rebuild breakdowns from the filtered daily entries so sessions that
722+ // straddle the period boundary only count in-range calls.
723+ let mut tool_bd: HashMap < String , ToolStats > = HashMap :: new ( ) ;
724+ let mut bash_bd: HashMap < String , ToolStats > = HashMap :: new ( ) ;
725+ let mut mcp_bd: HashMap < String , ToolStats > = HashMap :: new ( ) ;
726+ let mut model_bd: HashMap < String , ModelStats > = HashMap :: new ( ) ;
727+
707728 for d in & s. daily_costs {
708729 total_cost += d. cost_usd ;
709730 api_calls += d. call_count ;
710731 total_input += d. input_tokens ;
711732 total_output += d. output_tokens ;
712733 total_cache_read += d. cache_read_tokens ;
713734 total_cache_write += d. cache_write_tokens ;
735+
736+ for ( tool, & count) in & d. tool_breakdown {
737+ tool_bd. entry ( tool. clone ( ) ) . or_insert_with ( ToolStats :: default) . calls += count;
738+ }
739+ for ( cmd, & count) in & d. bash_breakdown {
740+ bash_bd. entry ( cmd. clone ( ) ) . or_insert_with ( ToolStats :: default) . calls += count;
741+ }
742+ for ( server, & count) in & d. mcp_breakdown {
743+ mcp_bd. entry ( server. clone ( ) ) . or_insert_with ( ToolStats :: default) . calls += count;
744+ }
745+ for ( model, & count) in & d. model_breakdown {
746+ model_bd. entry ( model. clone ( ) ) . or_insert_with ( ModelStats :: default) . calls += count;
747+ }
714748 }
715749 if api_calls == 0 {
716750 return None ;
@@ -722,19 +756,44 @@ fn filter_session_for_range(
722756 s. total_output_tokens = total_output;
723757 s. total_cache_read_tokens = total_cache_read;
724758 s. total_cache_write_tokens = total_cache_write;
759+
760+ // Only overwrite breakdowns if per-day data is available (cache entries
761+ // written before this change will have empty daily breakdowns — fall back
762+ // to the session-level breakdown for those).
763+ if !tool_bd. is_empty ( ) {
764+ s. tool_breakdown = tool_bd;
765+ }
766+ if !bash_bd. is_empty ( ) {
767+ s. bash_breakdown = bash_bd;
768+ }
769+ if !mcp_bd. is_empty ( ) {
770+ s. mcp_breakdown = mcp_bd;
771+ }
772+ if !model_bd. is_empty ( ) {
773+ s. model_breakdown = model_bd;
774+ }
775+
725776 Some ( s)
726777}
727778
728- /// `end_day` is a "YYYY-MM-DD" string — to compare against a 19-char timestamp
729- /// prefix we need "YYYY-MM-DDT23:59:59" (or anything lexically >= the max
730- /// in-day prefix). Returning "YYYY-MM-DDZ" works since 'Z' > 'T' > digits so
731- /// any in-day ts prefix sorts below it.
732- fn end_day_upper_bound ( end_day : & str ) -> String {
733- // "2026-04-17" → "2026-04-17Z" (Z sorts above any "T…" suffix)
734- let mut s = String :: with_capacity ( end_day. len ( ) + 1 ) ;
735- s. push_str ( end_day) ;
736- s. push ( 'Z' ) ;
737- s
779+ /// Convert a UTC ISO-8601 timestamp (e.g. "2026-04-14T17:30:00Z") to a local
780+ /// date string "YYYY-MM-DD". This matches the JS `dateKey` function which uses
781+ /// `new Date(iso).getDate()` (local time). Falls back to the first 10 chars
782+ /// of the input (UTC date) if parsing fails.
783+ fn utc_ts_to_local_day ( ts : & str ) -> String {
784+ // Try full ISO parse first (handles "…Z" and "+00:00" suffixes)
785+ if let Ok ( dt) = chrono:: DateTime :: parse_from_rfc3339 ( ts) {
786+ return dt. with_timezone ( & Local ) . format ( "%Y-%m-%d" ) . to_string ( ) ;
787+ }
788+ // Try the common "YYYY-MM-DDTHH:MM:SS" without offset (assume UTC)
789+ if ts. len ( ) >= 19 {
790+ if let Ok ( naive) = chrono:: NaiveDateTime :: parse_from_str ( & ts[ ..19 ] , "%Y-%m-%dT%H:%M:%S" ) {
791+ let utc = chrono:: Utc . from_utc_datetime ( & naive) ;
792+ return utc. with_timezone ( & Local ) . format ( "%Y-%m-%d" ) . to_string ( ) ;
793+ }
794+ }
795+ // Fallback: first 10 chars (UTC date)
796+ ts. get ( ..10 ) . unwrap_or ( ts) . to_string ( )
738797}
739798
740799/// Group cached session summaries (and fresh miss summaries) by project,
@@ -746,16 +805,12 @@ fn merge_and_filter_sessions(
746805 miss_sessions : Vec < ( String , Vec < SessionSummary > ) > ,
747806 date_range : Option < & DateRange > ,
748807) -> HashMap < String , Vec < SessionSummary > > {
808+ // Use local dates to match the local-time daily bucketing in
809+ // build_session_summary and the JS version's dateKey / getDateRange.
749810 let ( start_day, end_day) = match date_range {
750811 Some ( dr) => (
751- dr. start
752- . with_timezone ( & chrono:: Utc )
753- . format ( "%Y-%m-%d" )
754- . to_string ( ) ,
755- dr. end
756- . with_timezone ( & chrono:: Utc )
757- . format ( "%Y-%m-%d" )
758- . to_string ( ) ,
812+ dr. start . format ( "%Y-%m-%d" ) . to_string ( ) ,
813+ dr. end . format ( "%Y-%m-%d" ) . to_string ( ) ,
759814 ) ,
760815 None => ( String :: new ( ) , String :: new ( ) ) ,
761816 } ;
0 commit comments