Skip to content

Commit 2908c4c

Browse files
authored
fix(statusline): respect timezone for today cost (#1121)
Add a statusline timezone option so the compact today total uses the same date grouping semantics as daily reports instead of forcing UTC. The statusline today loader now builds its since/until filter from the requested IANA timezone and passes that timezone through to entry loading, keeping the filtered entry dates aligned with the selected local day. Update CLI help, config schema coverage, and snapshots for the new option. Fixes #1114.
1 parent ccc9724 commit 2908c4c

9 files changed

Lines changed: 80 additions & 20 deletions

apps/ccusage/config-schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,11 @@
973973
"minimum": 0.0,
974974
"type": "integer"
975975
},
976+
"timezone": {
977+
"description": "Timezone for date grouping (IANA).",
978+
"markdownDescription": "Timezone for date grouping (IANA).",
979+
"type": "string"
980+
},
976981
"visualBurnRate": {
977982
"default": "off",
978983
"description": "Visual burn-rate display mode.",
@@ -2667,6 +2672,11 @@
26672672
"minimum": 0.0,
26682673
"type": "integer"
26692674
},
2675+
"timezone": {
2676+
"description": "Timezone for date grouping (IANA).",
2677+
"markdownDescription": "Timezone for date grouping (IANA).",
2678+
"type": "string"
2679+
},
26702680
"visualBurnRate": {
26712681
"default": "off",
26722682
"description": "Visual burn-rate display mode.",

rust/crates/ccusage/src/cli-help.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@
367367
"description": "Context usage percentage below which status is shown in yellow (0-100)",
368368
"default": "80"
369369
},
370+
{
371+
"flags": "-z, --timezone <timezone>",
372+
"description": "Timezone for date grouping (IANA)"
373+
},
370374
{
371375
"flags": "--config <config>",
372376
"description": "Path to configuration file",

rust/crates/ccusage/src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ pub(crate) struct StatuslineArgs {
110110
pub(crate) refresh_interval: u64,
111111
pub(crate) context_low_threshold: u8,
112112
pub(crate) context_medium_threshold: u8,
113+
pub(crate) timezone: Option<String>,
113114
pub(crate) config: Option<PathBuf>,
114115
pub(crate) debug: bool,
115116
}
@@ -164,6 +165,7 @@ impl Default for StatuslineArgs {
164165
refresh_interval: 1,
165166
context_low_threshold: 50,
166167
context_medium_threshold: 80,
168+
timezone: None,
167169
config: None,
168170
debug: false,
169171
}
@@ -357,6 +359,7 @@ fn parse_command(
357359
"Invalid value for --context-medium-threshold".to_string()
358360
})?
359361
}
362+
"-z" | "--timezone" => args.timezone = Some(parser.value_for("--timezone")?),
360363
"--config" => args.config = Some(PathBuf::from(parser.value_for("--config")?)),
361364
"--debug" => args.debug = true,
362365
flag => return Err(format!("Unknown statusline option '{flag}'")),
@@ -2358,6 +2361,8 @@ mod tests {
23582361
"ccusage",
23592362
"statusline",
23602363
"--no-cache",
2364+
"--timezone",
2365+
"Asia/Tokyo",
23612366
"--visual-burn-rate",
23622367
"emoji-text",
23632368
"--cost-source",
@@ -2368,6 +2373,7 @@ mod tests {
23682373
};
23692374
assert!(args.offline);
23702375
assert!(args.no_cache);
2376+
assert_eq!(args.timezone.as_deref(), Some("Asia/Tokyo"));
23712377
assert_eq!(args.visual_burn_rate, VisualBurnRate::EmojiText);
23722378
assert_eq!(args.cost_source, CostSource::Both);
23732379
}

rust/crates/ccusage/src/commands/mod.rs

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,13 @@ use crate::{
1818
},
1919
color,
2020
fast::FxHashMap,
21-
filter_and_sort_summaries, filter_blocks_by_date, format_compact_utc_date, format_currency,
22-
format_number, format_remaining_time, format_rfc3339_millis, group_project_output,
23-
identify_session_blocks, load_daily_summaries, load_entries, print_active_block_detail,
24-
print_blocks_table, print_json_or_jq, print_usage_table, session_summary_json, sort_blocks,
25-
sort_summaries, summarize_by_key, summarize_summaries_by_bucket, summary_json,
26-
total_usage_tokens, totals_json, utc_now, wants_json, BucketKind, Color, Context, Result,
27-
SessionAccumulator, TimestampMs, DEFAULT_RECENT_DAYS, DEFAULT_SESSION_DURATION_HOURS,
28-
MILLIS_PER_DAY, MILLIS_PER_MINUTE,
21+
filter_and_sort_summaries, filter_blocks_by_date, format_currency, format_date, format_number,
22+
format_remaining_time, format_rfc3339_millis, group_project_output, identify_session_blocks,
23+
load_daily_summaries, load_entries, print_active_block_detail, print_blocks_table,
24+
print_json_or_jq, print_usage_table, session_summary_json, sort_blocks, sort_summaries,
25+
summarize_by_key, summarize_summaries_by_bucket, summary_json, total_usage_tokens, totals_json,
26+
utc_now, wants_json, BucketKind, Color, Context, Result, SessionAccumulator, TimestampMs,
27+
DEFAULT_RECENT_DAYS, DEFAULT_SESSION_DURATION_HOURS, MILLIS_PER_DAY, MILLIS_PER_MINUTE,
2928
};
3029

3130
pub(crate) fn run_daily(args: DailyArgs) -> Result<()> {
@@ -412,13 +411,7 @@ fn render_statusline(
412411
None
413412
};
414413

415-
let today = format_compact_utc_date(utc_now());
416-
let today_shared = SharedArgs {
417-
since: Some(today.clone()),
418-
until: Some(today),
419-
offline: shared.offline,
420-
..SharedArgs::default()
421-
};
414+
let today_shared = statusline_today_shared(args, shared, utc_now());
422415
let today_cost = load_entries(&today_shared, None)
423416
.map(|entries| {
424417
entries
@@ -516,6 +509,21 @@ fn render_statusline(
516509
))
517510
}
518511

512+
fn statusline_today_shared(
513+
args: &StatuslineArgs,
514+
shared: &SharedArgs,
515+
now: TimestampMs,
516+
) -> SharedArgs {
517+
let today = format_date(now, args.timezone.as_deref()).replace('-', "");
518+
SharedArgs {
519+
since: Some(today.clone()),
520+
until: Some(today),
521+
offline: shared.offline,
522+
timezone: args.timezone.clone(),
523+
..SharedArgs::default()
524+
}
525+
}
526+
519527
fn calculate_session_cost(session_id: &str, shared: &SharedArgs) -> Result<f64> {
520528
Ok(load_entries(shared, None)?
521529
.into_iter()
@@ -846,6 +854,25 @@ mod tests {
846854
assert!(format_statusline_context(120_000, 200_000, &args, &shared).contains("60%"));
847855
}
848856

857+
#[test]
858+
fn builds_statusline_today_filter_from_timezone() {
859+
let args = StatuslineArgs {
860+
timezone: Some("Asia/Tokyo".to_string()),
861+
..StatuslineArgs::default()
862+
};
863+
let shared = SharedArgs {
864+
offline: true,
865+
..SharedArgs::default()
866+
};
867+
let now = TimestampMs::from_millis(1_779_380_820_000);
868+
869+
let today_shared = statusline_today_shared(&args, &shared, now);
870+
871+
assert_eq!(today_shared.since.as_deref(), Some("20260522"));
872+
assert_eq!(today_shared.until.as_deref(), Some("20260522"));
873+
assert_eq!(today_shared.timezone.as_deref(), Some("Asia/Tokyo"));
874+
}
875+
849876
#[test]
850877
fn reuses_statusline_cache_while_fresh_and_transcript_unchanged() {
851878
let cache = StatuslineCache {

rust/crates/ccusage/src/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ pub(crate) fn apply_config_to_statusline_args(args: &mut StatuslineArgs, config:
341341
{
342342
args.context_medium_threshold = threshold;
343343
}
344+
if let Some(timezone) = options.timezone {
345+
args.timezone = Some(timezone);
346+
}
344347
if let Some(debug) = options.debug {
345348
args.debug = debug;
346349
}
@@ -570,6 +573,7 @@ mod tests {
570573
"refreshInterval": 3,
571574
"contextLowThreshold": 45,
572575
"contextMediumThreshold": 75,
576+
"timezone": "Asia/Tokyo",
573577
"debug": true
574578
}
575579
}
@@ -605,6 +609,7 @@ mod tests {
605609
"refreshInterval": 3,
606610
"contextLowThreshold": 45,
607611
"contextMediumThreshold": 75,
612+
"timezone": "Asia/Tokyo",
608613
"debug": true
609614
}
610615
}
@@ -625,6 +630,7 @@ mod tests {
625630
assert_eq!(statusline.refresh_interval, 3);
626631
assert_eq!(statusline.context_low_threshold, 45);
627632
assert_eq!(statusline.context_medium_threshold, 75);
633+
assert_eq!(statusline.timezone.as_deref(), Some("Asia/Tokyo"));
628634
assert!(statusline.debug);
629635
}
630636

rust/crates/ccusage/src/config_schema.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,8 @@ pub(crate) struct StatuslineSpecificOptions {
428428
pub(crate) context_low_threshold: Option<u64>,
429429
/// Percentage threshold for medium context warning.
430430
pub(crate) context_medium_threshold: Option<u64>,
431+
/// Timezone for date grouping (IANA).
432+
pub(crate) timezone: Option<String>,
431433
/// Show statusline debug information.
432434
pub(crate) debug: Option<bool>,
433435
}
@@ -577,6 +579,7 @@ impl StatuslineSpecificOptions {
577579
refresh_interval: u64_option(map, "refreshInterval"),
578580
context_low_threshold: u64_option(map, "contextLowThreshold"),
579581
context_medium_threshold: u64_option(map, "contextMediumThreshold"),
582+
timezone: string_option(map, "timezone"),
580583
debug: bool_option(map, "debug"),
581584
}
582585
}
@@ -970,6 +973,7 @@ mod tests {
970973
"noOffline",
971974
"offline",
972975
"refreshInterval",
976+
"timezone",
973977
"visualBurnRate",
974978
],
975979
);

rust/crates/ccusage/src/date_utils.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,6 @@ pub(crate) fn format_utc_date(timestamp: TimestampMs) -> String {
242242
format_date_parts(parts.year, parts.month, parts.day)
243243
}
244244

245-
pub(crate) fn format_compact_utc_date(timestamp: TimestampMs) -> String {
246-
let parts = timestamp.utc_parts();
247-
format!("{:04}{:02}{:02}", parts.year, parts.month, parts.day)
248-
}
249-
250245
pub(crate) fn format_naive_date(date: IsoDate) -> String {
251246
format_date_parts(date.year, date.month, date.day)
252247
}

rust/crates/ccusage/src/snapshots/ccusage__cli__tests__statusline_help.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/ccusage/src/cli.rs
3+
assertion_line: 2062
34
expression: "help_text_for_args(&[\"ccusage\".to_string(), \"statusline\".to_string()])"
45
---
56
Display compact status line for Claude Code hooks with hybrid time+file caching (Beta)
@@ -17,6 +18,7 @@ OPTIONS:
1718
--refresh-interval [refresh-interval] Refresh interval in seconds for cache expiry (default: 1)
1819
--context-low-threshold [context-low-threshold] Context usage percentage below which status is shown in green (0-100) (default: 50)
1920
--context-medium-threshold [context-medium-threshold] Context usage percentage below which status is shown in yellow (0-100) (default: 80)
21+
-z, --timezone <timezone> Timezone for date grouping (IANA)
2022
--config <config> Path to configuration file (default: auto-discovery)
2123
-d, --debug Show pricing mismatch information for debugging (default: false)
2224
-h, --help Display this help message

rust/crates/ccusage/src/snapshots/ccusage__config_schema__tests__snapshots_schema_agent_specific_option_edges.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/ccusage/src/config_schema.rs
3+
assertion_line: 1257
34
expression: "json!({\n \"rootRef\": schema[\"$ref\"], \"rootProperties\":\n definition_properties(&schema, \"ccusage-config\"),\n \"rootAdditionalProperties\":\n schema[\"definitions\"][\"ccusage-config\"][\"additionalProperties\"],\n \"defaults\": schema_node(&schema, &[\"defaults\"]), \"rootDaily\":\n schema_node(&schema, &[\"commands\", \"daily\"]), \"claudeStatusline\":\n schema_node(&schema, &[\"claude\", \"commands\", \"statusline\"]),\n \"codexDefaults\": schema_node(&schema, &[\"codex\", \"defaults\"]),\n \"opencodeWeekly\":\n schema_node(&schema, &[\"opencode\", \"commands\", \"weekly\"]), \"piDefaults\":\n schema_node(&schema, &[\"pi\", \"defaults\"]), \"openclawDefaults\":\n schema_node(&schema, &[\"openclaw\", \"defaults\"]),\n})"
45
---
56
{
@@ -72,6 +73,11 @@ expression: "json!({\n \"rootRef\": schema[\"$ref\"], \"rootProperties\":\n
7273
"minimum": 0.0,
7374
"type": "integer"
7475
},
76+
"timezone": {
77+
"description": "Timezone for date grouping (IANA).",
78+
"markdownDescription": "Timezone for date grouping (IANA).",
79+
"type": "string"
80+
},
7581
"visualBurnRate": {
7682
"default": "off",
7783
"description": "Visual burn-rate display mode.",

0 commit comments

Comments
 (0)