Skip to content

Commit f03a4c7

Browse files
committed
fix: Timeline sparkline axis labels positioning & timezone handling
- daily_usage() now pre-fills the full date range with zeros (n_bars == days) so bars span the chart width proportionally instead of clustering left - format_axis_labels() accepts n_bars param; positions first/mid/last labels at actual bar columns (1 + bar_index) matching Sparkline's 1-char-per-point - format_hourly_axis_labels() rewritten to place labels at exact bar positions: 0h→1, 6h→7, 12h→13, 18h→19, 23h→24 (no longer distributed across full width) - Switched to SQLite 'localtime' modifier for date/hour grouping so users see wall-clock times in their timezone, not UTC - All three Timeline chart titles now display UTC offset indicator (e.g. UTC+7) - test_daily_usage updated to validate pre-fill: assert len == 365 and check today's record at daily.last().total_tokens Fixes incorrect axis label positioning where sparse data (e.g. 10 days of records) caused labels to appear at terminal edges while bars clustered at left 5%.
1 parent 6a3fe65 commit f03a4c7

10 files changed

Lines changed: 561 additions & 127 deletions

File tree

.agents/CONTEXT.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ Displays a Ratatui dashboard with HTML export. Pricing fetched live from models.
1212

1313
## State
1414

15-
**Status:** Feature-complete -- 7 new features shipped; 34 tests passing
15+
**Status:** Feature-complete — v0.2.2 + Timeline axis fix; 34 tests passing, clippy clean
1616
**Branch:** main | **Worktree:** ./
17-
**Last completed:** Sessions tab (tab 6), CSV export (`c` key + `--csv` flag), hourly heatmap
18-
sparkline in Timeline, Avg/Req column in By Model, cache token extraction from OpenCode,
19-
`source_session_id` column for session grouping, `fmt_duration` helper, `write_csv()` in
20-
export.rs. Settings shifts to tab 7, `'6'` = Sessions, `'7'` = Settings.
21-
**Next:** Budget alerts, Zed sync (once Zed exposes token counts), auto-sync on startup daemon
17+
**Last completed (2026-04-04 session 4):**
18+
- Timeline sparkline axis label positioning corrected:
19+
- `daily_usage()` pre-fills the full date range with zeros so n_bars == days and sparkline fills proportionally
20+
- `format_axis_labels()` takes `n_bars` param; labels positioned at actual bar columns (`1 + bar_index`)
21+
- `format_hourly_axis_labels()` rewritten; labels at exact bar cols: 0h→1, 6h→7, 12h→13, 18h→19, 23h→24
22+
- Timezone: `date(recorded_at, 'localtime')` and `strftime('%H', recorded_at, 'localtime')` for local wall-clock times
23+
- UTC offset label appended to all three Timeline chart titles (e.g. "UTC+7", "UTC-5", "UTC")
24+
- `test_daily_usage` updated to assert `daily.len() == 365` and check `daily.last().total_tokens`
25+
26+
**Next:** Budget alerts, Zed sync (once Zed exposes token counts)
2227

2328
## Blockers
2429

@@ -46,6 +51,9 @@ None.
4651
| 2026-04-04 | input/output_cost_usd at display time | Avoids DB migration on price changes; user overrides apply instantly |
4752
| 2026-04-04 | source_session_id separate from session_id | session_id is dedup key ("opencode:<msg_id>"); source_session_id is OpenCode session FK for grouping |
4853
| 2026-04-04 | cache tokens stored but not shown in By Model | Reserved for future UI; suppressed via #[allow(dead_code)] |
54+
| 2026-04-04 | add_price_override updates both pricing maps | immediate effect without TUI restart |
55+
| 2026-04-04 | settings_confirm_edit returns Option<&'static str> | allows caller to display error in status bar |
56+
| 2026-04-04 | startup sync via spawn_blocking + JoinHandle poll | non-blocking; polled each event loop tick via is_finished() |
4957

5058
## Architecture Notes
5159

@@ -70,6 +78,15 @@ None.
7078
| 5 | Sessions | 6 |
7179
| 6 | Settings | 7 |
7280

81+
## Settings Items (index → label → interaction)
82+
83+
| Index | Label | Interaction |
84+
| ----- | -------------- | -------------------------------------------- |
85+
| 0 | OpenCode sync | Space to toggle |
86+
| 1 | Report path | Enter to edit text field |
87+
| 2 | Price override | Enter to enter model:in:out, updates config |
88+
| 3 | Reset DB | Enter (arm) → Enter again (execute), Esc=cancel |
89+
7390
## Sync Adapters
7491

7592
| Source | Status | DB/File Location | Token Source |
@@ -98,4 +115,8 @@ fresh data and update the cache.
98115

99116
---
100117

101-
_Updated: 2026-04-04 (Sessions tab, CSV export, hourly heatmap, Avg/Req column, cache tokens, source_session_id)_
118+
| 2026-04-04 | Timeline axis labels use bar-column positioning | Labels distributed across widget width were wrong; now placed at `1 + bar_index` matching Sparkline's left-aligned 1-char-per-point rendering |
119+
| 2026-04-04 | daily_usage() pre-fills all days in range | Sparse SQL results left most bars at col ~1-10 while labels spanned full width |
120+
| 2026-04-04 | SQLite 'localtime' modifier for date/hour grouping | UTC dates/hours were misleading for users outside UTC |
121+
122+
_Updated: 2026-04-04 (Timeline axis fix: bar-position labels, localtime, UTC offset in titles)_

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
[package]
22
name = "oxidetrack"
3-
version = "0.2.0"
3+
version = "0.2.2"
44
edition = "2021"
5-
description = "Lightweight TUI for tracking AI token usage across OpenCode, Zed, and VS Code Copilot"
6-
authors = ["Riy"]
5+
description = "Lightweight TUI for tracking AI token usage in OpenCode"
6+
authors = ["Riyaldi Hasan"]
77

88
[[bin]]
99
name = "oxtrack"

src/app.rs

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,16 @@ pub struct App {
4242
pub hourly: Vec<HourlyBucket>,
4343

4444
// Settings tab state
45-
/// Currently selected settings item index (0-4)
45+
/// Currently selected settings item index (0-3)
4646
pub settings_selected: usize,
4747
/// Whether we're in editing mode for a text field
4848
pub settings_editing: bool,
4949
/// Buffer for the text being edited
5050
pub settings_edit_buffer: String,
5151
/// Whether settings have unsaved changes
5252
pub settings_dirty: bool,
53+
/// Pending "Reset DB" confirmation — first Enter arms, second Enter executes
54+
pub settings_reset_pending: bool,
5355

5456
// Status bar message
5557
pub status: String,
@@ -85,6 +87,7 @@ impl App {
8587
settings_editing: false,
8688
settings_edit_buffer: String::new(),
8789
settings_dirty: false,
90+
settings_reset_pending: false,
8891
status: "Press ? for help | s = sync | e = export | c = csv | q = quit".into(),
8992
should_quit: false,
9093
needs_clear: false,
@@ -209,28 +212,34 @@ impl App {
209212

210213
// ── Settings ──────────────────────────────────────────────────────────────
211214

212-
/// Total number of editable settings items:
213-
/// 0 = OpenCode sync toggle, 1 = Report path text field.
214-
pub const SETTINGS_COUNT: usize = 2;
215+
/// Total number of navigable settings items:
216+
/// 0 = OpenCode sync toggle, 1 = Report path, 2 = Price override, 3 = Reset DB.
217+
pub const SETTINGS_COUNT: usize = 4;
215218

216219
/// Toggle a boolean setting at the current index.
217220
pub fn settings_toggle(&mut self) {
218221
if self.settings_selected == 0 {
219222
self.config.sources.opencode = !self.config.sources.opencode;
220223
self.settings_dirty = true;
221224
}
222-
// item 1 (Report path) is a text field — use Enter, not Space
225+
// items 1-3 use Enter, not Space
223226
}
224227

225228
/// Begin editing a text field at the current index.
226229
/// Returns false if the current item is not a text field.
227230
pub fn settings_start_edit(&mut self) -> bool {
228-
if self.settings_selected == 1 {
229-
self.settings_edit_buffer = self.config.export_path.clone().unwrap_or_default();
230-
self.settings_editing = true;
231-
true
232-
} else {
233-
false // booleans use Space, not Enter
231+
match self.settings_selected {
232+
1 => {
233+
self.settings_edit_buffer = self.config.export_path.clone().unwrap_or_default();
234+
self.settings_editing = true;
235+
true
236+
}
237+
2 => {
238+
self.settings_edit_buffer = String::new();
239+
self.settings_editing = true;
240+
true
241+
}
242+
_ => false, // booleans / actions use Space or Enter directly
234243
}
235244
}
236245

@@ -241,14 +250,48 @@ impl App {
241250
}
242251

243252
/// Confirm the current edit, writing the buffer value into config.
244-
pub fn settings_confirm_edit(&mut self) {
253+
///
254+
/// Returns `Some(&'static str)` with an error message on validation failure,
255+
/// or `None` on success.
256+
pub fn settings_confirm_edit(&mut self) -> Option<&'static str> {
245257
let val = self.settings_edit_buffer.trim().to_string();
246-
if self.settings_selected == 1 {
247-
self.config.export_path = if val.is_empty() { None } else { Some(val) };
248-
self.settings_dirty = true;
258+
match self.settings_selected {
259+
1 => {
260+
self.config.export_path = if val.is_empty() { None } else { Some(val) };
261+
self.settings_dirty = true;
262+
}
263+
2 => {
264+
// Parse "model:input_per_1m:output_per_1m"
265+
let parts: Vec<&str> = val.splitn(3, ':').collect();
266+
if parts.len() != 3 {
267+
self.settings_editing = false;
268+
self.settings_edit_buffer.clear();
269+
return Some("Format: model:in$/1M:out$/1M e.g. gpt-4o:2.5:10.0");
270+
}
271+
let model = parts[0].trim().to_string();
272+
let Ok(input_p) = parts[1].trim().parse::<f64>() else {
273+
self.settings_editing = false;
274+
self.settings_edit_buffer.clear();
275+
return Some("Invalid input price — must be a number");
276+
};
277+
let Ok(output_p) = parts[2].trim().parse::<f64>() else {
278+
self.settings_editing = false;
279+
self.settings_edit_buffer.clear();
280+
return Some("Invalid output price — must be a number");
281+
};
282+
if model.is_empty() {
283+
self.settings_editing = false;
284+
self.settings_edit_buffer.clear();
285+
return Some("Model name cannot be empty");
286+
}
287+
self.config.add_price_override(model, input_p, output_p);
288+
self.settings_dirty = true;
289+
}
290+
_ => {}
249291
}
250292
self.settings_editing = false;
251293
self.settings_edit_buffer.clear();
294+
None
252295
}
253296

254297
/// Save the current config to disk.

src/config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,15 @@ impl Config {
194194
.unwrap_or([0.0, 0.0])
195195
}
196196

197+
/// Add or overwrite a price override for `model`, taking effect immediately.
198+
///
199+
/// Updates both `self.pricing` (persisted to config.toml on next save) and
200+
/// `self.effective_pricing` (used by `model_pricing` / `calc_cost` right away).
201+
pub fn add_price_override(&mut self, model: String, input: f64, output: f64) {
202+
self.pricing.insert(model.clone(), [input, output]);
203+
self.effective_pricing.insert(model, [input, output]);
204+
}
205+
197206
/// Calculate total cost in USD for a given model and token counts.
198207
pub fn calc_cost(&self, model: &str, input: i64, output: i64) -> f64 {
199208
let [in_price, out_price] = self.model_pricing(model);

src/db.rs

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use anyhow::Result;
2-
use chrono::{Duration, Utc};
2+
use chrono::{Duration, Local, NaiveDate, Utc};
33
use rusqlite::{params, Connection};
44
use serde::{Deserialize, Serialize};
55
use std::path::Path;
@@ -334,6 +334,24 @@ impl Database {
334334
Ok(deleted)
335335
}
336336

337+
/// Delete ALL usage records and sync state, so the next sync re-imports
338+
/// everything fresh. Returns the number of usage records deleted.
339+
pub fn reset_records(&self) -> Result<usize> {
340+
self.conn.execute_batch("BEGIN")?;
341+
let result = (|| -> Result<usize> {
342+
let deleted = self.conn.execute("DELETE FROM usage_records", [])?;
343+
self.conn.execute("DELETE FROM sync_state", [])?;
344+
Ok(deleted)
345+
})();
346+
match &result {
347+
Ok(_) => self.conn.execute_batch("COMMIT")?,
348+
Err(_) => {
349+
let _ = self.conn.execute_batch("ROLLBACK");
350+
}
351+
}
352+
result
353+
}
354+
337355
pub fn update_sync_state(&self, source: &str) -> Result<()> {
338356
self.conn.execute(
339357
"INSERT OR REPLACE INTO sync_state (source, last_sync) VALUES (?1, ?2)",
@@ -491,31 +509,48 @@ impl Database {
491509

492510
pub fn daily_usage(&self, days: i64) -> Result<Vec<DailyUsage>> {
493511
let since = Self::since(days);
512+
// Use 'localtime' modifier so dates match the user's wall-clock day.
494513
let mut stmt = self.conn.prepare(
495-
"SELECT date(recorded_at) as day,
514+
"SELECT date(recorded_at, 'localtime') as day,
496515
SUM(input_tokens + output_tokens),
497516
SUM(cost_usd)
498517
FROM usage_records
499518
WHERE recorded_at >= ?1
500519
GROUP BY day
501520
ORDER BY day ASC",
502521
)?;
503-
let rows = stmt.query_map(params![since], |row| {
504-
Ok(DailyUsage {
505-
date: row.get(0)?,
506-
total_tokens: row.get(1)?,
507-
cost_usd: row.get(2)?,
508-
})
509-
})?;
510-
Ok(rows
522+
let found: std::collections::HashMap<String, (i64, f64)> = stmt
523+
.query_map(params![since], |row| {
524+
Ok((
525+
row.get::<_, String>(0)?,
526+
row.get::<_, i64>(1)?,
527+
row.get::<_, f64>(2)?,
528+
))
529+
})?
511530
.filter_map(|r| match r {
512-
Ok(row) => Some(row),
531+
Ok((date, tokens, cost)) => Some((date, (tokens, cost))),
513532
Err(e) => {
514533
eprintln!("warn: skipped malformed daily row: {}", e);
515534
None
516535
}
517536
})
518-
.collect())
537+
.collect();
538+
539+
// Pre-fill every day in the range (local time) so that n_bars == days and
540+
// the sparkline bars span the full chart width proportionally.
541+
let today_local = Local::now().date_naive();
542+
let mut result = Vec::with_capacity(days as usize);
543+
for offset in (0..days).rev() {
544+
let date: NaiveDate = today_local - Duration::days(offset);
545+
let key = date.format("%Y-%m-%d").to_string();
546+
let (total_tokens, cost_usd) = found.get(&key).copied().unwrap_or((0, 0.0));
547+
result.push(DailyUsage {
548+
date: key,
549+
total_tokens,
550+
cost_usd,
551+
});
552+
}
553+
Ok(result)
519554
}
520555

521556
pub fn by_source(&self, days: i64) -> Result<Vec<SourceRow>> {
@@ -607,8 +642,9 @@ impl Database {
607642
/// Returns a vec of 24 entries (one per hour), even if some hours are zero.
608643
pub fn hourly_usage(&self, days: i64) -> Result<Vec<HourlyBucket>> {
609644
let since = Self::since(days);
645+
// Use 'localtime' modifier so hours match the user's wall-clock time.
610646
let mut stmt = self.conn.prepare(
611-
"SELECT CAST(strftime('%H', recorded_at) AS INTEGER) AS hour,
647+
"SELECT CAST(strftime('%H', recorded_at, 'localtime') AS INTEGER) AS hour,
612648
SUM(input_tokens + output_tokens)
613649
FROM usage_records
614650
WHERE recorded_at >= ?1
@@ -770,8 +806,9 @@ mod tests {
770806
let records = vec![sample_record("s1", "claude-sonnet-4", 100, 200)];
771807
db.insert_records(&records).unwrap();
772808
let daily = db.daily_usage(365).unwrap();
773-
assert!(!daily.is_empty());
774-
assert_eq!(daily[0].total_tokens, 300);
809+
// Pre-fill ensures exactly `days` entries; today's record is the last one.
810+
assert_eq!(daily.len(), 365);
811+
assert_eq!(daily.last().unwrap().total_tokens, 300);
775812
}
776813

777814
#[test]

0 commit comments

Comments
 (0)