Skip to content

Commit 46fd52f

Browse files
committed
Store per-day breakdowns and use local dates for accurate range filterin
- Add tool/bash/mcp/model breakdown maps to DailyCostEntry so date-range filtering rebuilds counts from in-range days only - Convert UTC timestamps to local dates for daily bucketing, matching the JS version's dateKey behavior - Skip env-var prefixes and filter true/false in bash command extraction - Fix 30days range off-by-one (29-day offset to include today) - Bump cache version to 7
1 parent 8d5e5ea commit 46fd52f

5 files changed

Lines changed: 140 additions & 42 deletions

File tree

src/bash_utils.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use std::sync::LazyLock;
2121
static QUOTE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#""[^"]*"|'[^']*'"#).unwrap());
2222
static SEPARATOR_RE: LazyLock<Regex> =
2323
LazyLock::new(|| Regex::new(r"\s*(?:&&|;|\|)\s*").unwrap());
24+
static ENV_VAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\w+=").unwrap());
2425

2526
fn strip_quoted_strings(command: &str) -> String {
2627
QUOTE_RE
@@ -56,13 +57,18 @@ pub fn extract_bash_commands(command: &str) -> Vec<String> {
5657
if segment.is_empty() {
5758
continue;
5859
}
59-
let first_token = segment.split_whitespace().next().unwrap_or("");
60+
// Skip leading environment variable assignments (e.g. FOO=bar node script.js)
61+
let first_token = segment
62+
.split_whitespace()
63+
.skip_while(|t| ENV_VAR_RE.is_match(t))
64+
.next()
65+
.unwrap_or("");
6066
let base = Path::new(first_token)
6167
.file_name()
6268
.and_then(|n| n.to_str())
6369
.unwrap_or("");
6470

65-
if !base.is_empty() && base != "cd" {
71+
if !base.is_empty() && base != "cd" && base != "true" && base != "false" {
6672
commands.push(base.to_string());
6773
}
6874
}
@@ -137,4 +143,32 @@ mod tests {
137143
vec!["git", "head", "wc"]
138144
);
139145
}
146+
147+
#[test]
148+
fn test_env_var_prefix() {
149+
assert_eq!(
150+
extract_bash_commands("NODE_ENV=production npm run build"),
151+
vec!["npm"]
152+
);
153+
}
154+
155+
#[test]
156+
fn test_multiple_env_var_prefixes() {
157+
assert_eq!(
158+
extract_bash_commands("FOO=bar BAZ=qux node script.js"),
159+
vec!["node"]
160+
);
161+
}
162+
163+
#[test]
164+
fn test_true_false_filtered() {
165+
assert_eq!(
166+
extract_bash_commands("true && ls"),
167+
vec!["ls"]
168+
);
169+
assert_eq!(
170+
extract_bash_commands("false; echo done"),
171+
vec!["echo"]
172+
);
173+
}
140174
}

src/parser.rs

Lines changed: 93 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use std::collections::HashMap;
1717
use std::time::{Instant, SystemTime};
1818

1919
use anyhow::Result;
20-
use chrono::Local;
20+
use chrono::{Local, TimeZone};
2121
use dashmap::DashSet;
2222
use 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
};

src/report_cache.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const MAGIC: &[u8; 8] = b"CODEBRN1";
7272
// Cuts the hot path by skipping bincode-deserialise of ~100 MB of per-call
7373
// Strings + classify_turn for ~20k turns on every run. Old v5 caches hold
7474
// raw-call blobs whose shape no longer matches, so we have to reject them.
75-
const VERSION: u32 = 6;
75+
const VERSION: u32 = 7;
7676

7777
fn cache_path() -> PathBuf {
7878
dirs::home_dir()

src/tui/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ pub fn get_date_range(period: &str) -> Option<DateRange> {
187187
.unwrap()
188188
.and_local_timezone(Local)
189189
.unwrap(),
190-
"30days" => (now - chrono::Duration::days(30))
190+
"30days" => (now - chrono::Duration::days(29))
191191
.date_naive()
192192
.and_hms_opt(0, 0, 0)
193193
.unwrap()

src/types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ pub struct DailyCostEntry {
2828
pub output_tokens: u64,
2929
pub cache_read_tokens: u64,
3030
pub cache_write_tokens: u64,
31+
/// Per-day breakdown so date-range filtering produces accurate counts.
32+
#[serde(default)]
33+
pub tool_breakdown: HashMap<String, u64>,
34+
#[serde(default)]
35+
pub bash_breakdown: HashMap<String, u64>,
36+
#[serde(default)]
37+
pub mcp_breakdown: HashMap<String, u64>,
38+
#[serde(default)]
39+
pub model_breakdown: HashMap<String, u64>,
3140
}
3241

3342
#[derive(Debug, Clone, Default, Serialize, Deserialize)]

0 commit comments

Comments
 (0)