use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc;
use chrono::{Datelike, NaiveDate};
use crate::data::models::{StatsCache, ProjectDir, SessionSummary, ViewData};
use crate::data::compute;
use crate::i18n;
use crate::llm::{self, LlmReportType};
use crate::utils;
mod clipboard;
mod state;
use state::{AppLlmState, AppUiState, AppViewCache};
#[derive(Debug, Clone, PartialEq)]
pub enum Tab {
Summary,
Monthly,
Yearly,
LlmSummary,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LoadState {
Idle,
Loading,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FocusSection {
ModelBars,
LanguageBars,
ToolCalls,
ProjectTable,
}
impl FocusSection {
pub fn next(self) -> Self {
match self {
FocusSection::ModelBars => FocusSection::LanguageBars,
FocusSection::LanguageBars => FocusSection::ToolCalls,
FocusSection::ToolCalls => FocusSection::ProjectTable,
FocusSection::ProjectTable => FocusSection::ModelBars,
}
}
pub fn prev(self) -> Self {
match self {
FocusSection::ModelBars => FocusSection::ProjectTable,
FocusSection::LanguageBars => FocusSection::ModelBars,
FocusSection::ToolCalls => FocusSection::LanguageBars,
FocusSection::ProjectTable => FocusSection::ToolCalls,
}
}
pub fn name(&self) -> &str {
let t = i18n::t();
match self {
FocusSection::ModelBars => t.focus_model_bars,
FocusSection::LanguageBars => t.focus_language_bars,
FocusSection::ToolCalls => t.focus_tool_calls,
FocusSection::ProjectTable => t.focus_project_table,
}
}
}
enum LoadRequest {
Monthly(i32, u32),
Yearly(i32),
}
pub struct App {
pub stats: StatsCache,
pub project_dirs: Vec<ProjectDir>,
pub active_tab: Tab,
pub selected_month: NaiveDate,
pub selected_year: i32,
pub ui: AppUiState,
pub llm: AppLlmState,
pub views: AppViewCache,
pub language_distribution: HashMap<String, f64>,
pub lang_rx: Option<mpsc::Receiver<HashMap<String, f64>>>,
pub lang_scanning: bool,
pub parse_error_count: u64,
pub load_state: LoadState,
load_rx: Option<mpsc::Receiver<(LoadRequest, ViewData)>>,
first_session_date: Option<NaiveDate>,
llm_rx: Option<mpsc::Receiver<(String, u64, String, Option<String>)>>,
claude_dir: PathBuf,
}
impl App {
pub fn new(
stats: StatsCache,
_history: Vec<crate::data::models::HistoryEntry>,
project_dirs: Vec<ProjectDir>,
start_tab: Option<utils::StartTab>,
claude_dir: PathBuf,
) -> Self {
let today = chrono::Local::now().naive_local().date();
let selected_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap();
let selected_year = today.year();
let first_session_date = stats
.first_session_date
.as_deref()
.and_then(utils::parse_iso_timestamp)
.map(|dt| dt.date());
let project_paths: Vec<(String, String, bool)> = project_dirs
.iter()
.map(|p| (p.display_name.clone(), p.full_path.clone(), p.path_resolved))
.collect();
let (lang_tx, lang_rx) = mpsc::channel();
std::thread::spawn(move || {
let result = utils::detect_languages(&project_paths);
let _ = lang_tx.send(result);
});
let (active_tab, sel_month, sel_year) = match start_tab {
Some(utils::StartTab::Monthly(y, m)) => {
(Tab::Monthly,
NaiveDate::from_ymd_opt(y, m, 1).unwrap_or(selected_month),
y)
}
Some(utils::StartTab::Yearly(y)) => {
(Tab::Yearly,
NaiveDate::from_ymd_opt(y, 1, 1).unwrap_or(selected_month),
y)
}
_ => (Tab::Summary, selected_month, selected_year),
};
let mut app = App {
stats,
project_dirs,
active_tab,
selected_month: sel_month,
selected_year: sel_year,
ui: AppUiState::default(),
llm: AppLlmState::new(sel_month, sel_year),
views: AppViewCache::default(),
language_distribution: HashMap::new(),
lang_rx: Some(lang_rx),
lang_scanning: true,
parse_error_count: 0,
load_state: LoadState::Idle,
load_rx: None,
first_session_date,
llm_rx: None,
claude_dir,
};
app.load_all_time_data();
let need_view_load = app.active_tab != Tab::Summary;
if need_view_load {
app.load_view_data();
}
app
}
pub fn handle_event(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
if self.active_tab == Tab::LlmSummary {
match key.code {
KeyCode::Char('a') | KeyCode::Char('A') => {
self.llm.report_type = LlmReportType::All;
self.generate_llm_report();
return;
}
KeyCode::Char('m') | KeyCode::Char('M') => {
self.llm.report_type = LlmReportType::Monthly;
self.generate_llm_report();
return;
}
KeyCode::Char('Y') => {
self.llm.report_type = LlmReportType::Yearly;
self.generate_llm_report();
return;
}
KeyCode::Char('T') | KeyCode::Char('t') => {
self.llm.thinking_mode = !self.llm.thinking_mode;
return;
}
KeyCode::Enter => {
self.generate_llm_report();
return;
}
_ => {}
}
}
match key.code {
KeyCode::Char('1') => self.switch_tab(Tab::Summary),
KeyCode::Char('2') => self.switch_tab(Tab::Monthly),
KeyCode::Char('3') => self.switch_tab(Tab::Yearly),
KeyCode::Char('4') => self.switch_tab(Tab::LlmSummary),
KeyCode::Tab => {
if crossterm::event::KeyModifiers::SHIFT == key.modifiers {
self.ui.focus_section = self.ui.focus_section.prev();
} else {
self.ui.focus_section = self.ui.focus_section.next();
}
}
KeyCode::Right => self.navigate_forward(),
KeyCode::Left => self.navigate_back(),
KeyCode::Up | KeyCode::Char('k') => self.scroll_focused(-1),
KeyCode::Down | KeyCode::Char('j') => self.scroll_focused(1),
KeyCode::PageUp => self.scroll_focused(-10),
KeyCode::PageDown => self.scroll_focused(10),
KeyCode::Char('y') => self.yank_to_clipboard(),
KeyCode::Char('r') => self.refresh_data(),
_ => {}
}
}
fn scroll_focused(&mut self, delta: i16) {
if self.active_tab == Tab::LlmSummary {
let offset = &mut self.llm.scroll;
if delta < 0 {
*offset = offset.saturating_sub(delta.unsigned_abs());
} else {
*offset = offset.saturating_add(delta as u16);
}
return;
}
let offset = match self.ui.focus_section {
FocusSection::ModelBars => &mut self.ui.model_scroll,
FocusSection::LanguageBars => &mut self.ui.lang_scroll,
FocusSection::ToolCalls => &mut self.ui.tool_scroll,
FocusSection::ProjectTable => &mut self.ui.project_scroll,
};
if delta < 0 {
*offset = offset.saturating_sub(delta.unsigned_abs());
} else {
*offset = offset.saturating_add(delta as u16);
}
}
pub fn tick(&mut self) {
if self.lang_scanning {
if let Some(ref rx) = self.lang_rx {
if let Ok(result) = rx.try_recv() {
self.language_distribution = result;
self.lang_rx = None;
self.lang_scanning = false;
}
}
}
if self.llm.load_state == LoadState::Loading {
let completed = if let Some(rx) = self.llm_rx.take() {
match rx.try_recv() {
Ok((key, mtime, text, thinking)) => Some((key, mtime, text, thinking)),
Err(mpsc::TryRecvError::Empty) => {
self.llm_rx = Some(rx);
None
}
Err(mpsc::TryRecvError::Disconnected) => {
self.llm.status_message = Some(i18n::t().llm_disconnected.to_string());
None
}
}
} else {
None
};
if let Some((key, mtime, text, thinking)) = completed {
self.llm.load_state = LoadState::Idle;
if text.starts_with("Error:") {
self.llm.status_message = Some(text);
} else {
let _ = llm::write_cache(&key, mtime, &text);
self.llm.data = Some(text);
self.llm.thinking_data = thinking;
self.llm.status_message = None;
}
}
}
if self.load_state == LoadState::Loading {
let completed = if let Some(rx) = self.load_rx.take() {
match rx.try_recv() {
Ok((req, data)) => Some((req, data)),
Err(mpsc::TryRecvError::Empty) => {
self.load_rx = Some(rx);
None
}
Err(mpsc::TryRecvError::Disconnected) => None,
}
} else {
None
};
if let Some((req, data)) = completed {
self.load_state = LoadState::Idle;
match req {
LoadRequest::Monthly(y, m) => {
self.views.monthly_cache.insert((y, m), data.clone());
if self.active_tab == Tab::Monthly
&& self.selected_month.year() == y
&& self.selected_month.month() == m
{
self.views.current_view_data = Some(data);
}
}
LoadRequest::Yearly(y) => {
self.views.yearly_cache.insert(y, data.clone());
if self.active_tab == Tab::Yearly && self.selected_year == y {
self.views.current_view_data = Some(data);
}
}
}
}
}
}
fn switch_tab(&mut self, tab: Tab) {
if self.load_state == LoadState::Loading || self.llm.load_state == LoadState::Loading {
return;
}
self.active_tab = tab;
self.reset_scrolls();
if self.active_tab != Tab::LlmSummary {
self.load_view_data();
}
}
fn reset_scrolls(&mut self) {
self.ui.model_scroll = 0;
self.ui.lang_scroll = 0;
self.ui.tool_scroll = 0;
self.ui.project_scroll = 0;
self.llm.scroll = 0;
}
fn navigate_forward(&mut self) {
if self.load_state == LoadState::Loading {
return;
}
let today = chrono::Local::now().naive_local().date();
match self.active_tab {
Tab::Monthly => {
let next = self.selected_month + chrono::Months::new(1);
let this_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap();
if next > this_month {
return;
}
self.selected_month = next;
self.reset_scrolls();
self.load_view_data();
}
Tab::Yearly => {
if self.selected_year >= today.year() {
return;
}
self.selected_year += 1;
self.reset_scrolls();
self.load_view_data();
}
Tab::LlmSummary => {
match self.llm.report_type {
LlmReportType::Monthly => {
let next = self.llm.selected_month + chrono::Months::new(1);
let this_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap();
if next > this_month { return; }
self.llm.selected_month = next;
}
LlmReportType::Yearly => {
if self.llm.selected_year >= today.year() { return; }
self.llm.selected_year += 1;
}
_ => {}
}
}
_ => {}
}
}
fn navigate_back(&mut self) {
if self.load_state == LoadState::Loading {
return;
}
match self.active_tab {
Tab::Monthly => {
let prev = self.selected_month - chrono::Months::new(1);
if let Some(first_date) = self.first_session_date {
let first_month =
NaiveDate::from_ymd_opt(first_date.year(), first_date.month(), 1).unwrap();
if prev < first_month {
return;
}
}
self.selected_month = prev;
self.reset_scrolls();
self.load_view_data();
}
Tab::Yearly => {
if let Some(first_date) = self.first_session_date {
if self.selected_year <= first_date.year() {
return;
}
}
self.selected_year -= 1;
self.reset_scrolls();
self.load_view_data();
}
Tab::LlmSummary => {
match self.llm.report_type {
LlmReportType::Monthly => {
let prev = self.llm.selected_month - chrono::Months::new(1);
if let Some(first_date) = self.first_session_date {
let first_month = NaiveDate::from_ymd_opt(first_date.year(), first_date.month(), 1).unwrap();
if prev < first_month { return; }
}
self.llm.selected_month = prev;
}
LlmReportType::Yearly => {
if let Some(first_date) = self.first_session_date {
if self.llm.selected_year <= first_date.year() { return; }
}
self.llm.selected_year -= 1;
}
_ => {}
}
}
_ => {}
}
}
fn generate_llm_report(&mut self) {
if self.llm.load_state == LoadState::Loading {
return;
}
let locale_suffix = match i18n::current_locale() {
i18n::Locale::Zh => "_zh",
i18n::Locale::En => "_en",
};
let key = match self.llm.report_type {
LlmReportType::All => format!("all{}", locale_suffix),
LlmReportType::Monthly => format!(
"monthly_{}_{:02}{}", self.llm.selected_month.year(), self.llm.selected_month.month(), locale_suffix
),
LlmReportType::Yearly => format!("yearly_{}{}", self.llm.selected_year, locale_suffix),
};
let source_mtime = llm::compute_source_mtime(&self.claude_dir);
if let Some((cached_mtime, cached_text)) = llm::read_cache(&key) {
if cached_mtime >= source_mtime {
self.llm.data = Some(cached_text);
self.llm.scroll = 0;
self.llm.status_message = Some(i18n::t().llm_cached.to_string());
return;
}
}
let (tx, rx) = mpsc::channel();
let report_type = self.llm.report_type;
let thinking_mode = self.llm.thinking_mode;
let stats = self.stats.clone();
let project_dirs = self.project_dirs.clone();
let api_config = llm::load_api_config(&self.claude_dir);
let claude_dir = self.claude_dir.clone();
let year = self.llm.selected_year;
let month = self.llm.selected_month.month();
let locale = i18n::current_locale();
self.llm.load_state = LoadState::Loading;
self.llm.data = None;
self.llm.thinking_data = None;
self.llm.scroll = 0;
self.llm.status_message = Some(if thinking_mode {
i18n::t().llm_generating_thinking.to_string()
} else {
i18n::t().llm_generating.to_string()
});
self.llm_rx = Some(rx);
std::thread::spawn(move || {
let view_data = match report_type {
LlmReportType::All => {
let start = stats.first_session_date.as_deref()
.and_then(crate::utils::parse_iso_timestamp)
.map(|dt| dt.date())
.unwrap_or_else(|| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
let end = chrono::Local::now().naive_local().date();
compute::compute_view_data_with_usage(
&project_dirs, &stats.daily_activity,
&stats.daily_model_tokens, &stats.model_usage,
start, end,
)
}
LlmReportType::Monthly => {
let (start, end) = crate::utils::month_bounds(year, month);
compute::compute_view_data_with_usage(
&project_dirs, &stats.daily_activity,
&stats.daily_model_tokens, &std::collections::HashMap::new(),
start, end,
)
}
LlmReportType::Yearly => {
let (start, end) = crate::utils::year_bounds(year);
compute::compute_view_data_with_usage(
&project_dirs, &stats.daily_activity,
&stats.daily_model_tokens, &std::collections::HashMap::new(),
start, end,
)
}
};
let prompt = llm::build_prompt(report_type, &view_data, year, month, locale);
let result = match llm::call_haiku_api(&prompt, &api_config, thinking_mode) {
Ok((output, thinking)) => (output, thinking),
Err(e) => (format!("Error: {}", e), None),
};
let mtime_at_gen = llm::compute_source_mtime(&claude_dir);
let _ = tx.send((key, mtime_at_gen, result.0, result.1));
});
}
fn load_view_data(&mut self) {
match self.active_tab {
Tab::Summary | Tab::LlmSummary => {
self.views.current_view_data = None;
}
Tab::Monthly => {
let y = self.selected_month.year();
let m = self.selected_month.month();
let key = (y, m);
if let Some(data) = self.views.monthly_cache.get(&key) {
self.views.current_view_data = Some(data.clone());
return;
}
let (tx, rx) = mpsc::channel();
let project_dirs = self.project_dirs.clone();
let daily_activity = self.stats.daily_activity.clone();
let daily_model_tokens = self.stats.daily_model_tokens.clone();
std::thread::spawn(move || {
let (start, end) = utils::month_bounds(y, m);
let data = compute::compute_view_data_with_usage(&project_dirs, &daily_activity, &daily_model_tokens, &std::collections::HashMap::new(), start, end);
let _ = tx.send((LoadRequest::Monthly(y, m), data));
});
self.load_rx = Some(rx);
self.load_state = LoadState::Loading;
self.views.current_view_data = None;
}
Tab::Yearly => {
let y = self.selected_year;
let key = y;
if let Some(data) = self.views.yearly_cache.get(&key) {
self.views.current_view_data = Some(data.clone());
return;
}
let (tx, rx) = mpsc::channel();
let project_dirs = self.project_dirs.clone();
let daily_activity = self.stats.daily_activity.clone();
let daily_model_tokens = self.stats.daily_model_tokens.clone();
std::thread::spawn(move || {
let (start, end) = utils::year_bounds(y);
let data = compute::compute_view_data_with_usage(&project_dirs, &daily_activity, &daily_model_tokens, &std::collections::HashMap::new(), start, end);
let _ = tx.send((LoadRequest::Yearly(y), data));
});
self.load_rx = Some(rx);
self.load_state = LoadState::Loading;
self.views.current_view_data = None;
}
}
}
fn load_all_time_data(&mut self) {
let start = match self.first_session_date {
Some(d) => d,
None => return,
};
let end = chrono::Local::now().naive_local().date();
let daily_activity = self.stats.daily_activity.clone();
let daily_model_tokens = self.stats.daily_model_tokens.clone();
let model_usage = self.stats.model_usage.clone();
let project_dirs = self.project_dirs.clone();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let data = compute::compute_view_data_with_usage(&project_dirs, &daily_activity, &daily_model_tokens, &model_usage, start, end);
let _ = tx.send(data);
});
if let Ok(data) = rx.recv() {
self.views.cached_sessions = data
.project_tokens
.iter()
.map(|(name, tokens)| SessionSummary {
project_name: name.clone(),
model: String::new(),
total_tokens: *tokens,
input_tokens: 0, output_tokens: 0,
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
web_search_requests: 0, web_fetch_requests: 0,
tool_call_count: 0,
tool_calls: HashMap::new(),
})
.collect();
self.views.overview_view_data = Some(data);
self.views.loaded = true;
}
}
fn refresh_data(&mut self) {
if self.load_state == LoadState::Loading || self.llm.load_state == LoadState::Loading {
return;
}
self.views.monthly_cache.clear();
self.views.yearly_cache.clear();
self.llm.data = None;
self.llm.status_message = None;
self.reset_scrolls();
self.load_all_time_data();
if self.active_tab != Tab::LlmSummary {
self.load_view_data();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use chrono::NaiveDate;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn minimal_app() -> App {
let stats = StatsCache::default();
let sel = d(2026, 5, 1);
App {
stats,
project_dirs: vec![],
active_tab: Tab::Summary,
selected_month: sel,
selected_year: 2026,
ui: AppUiState::default(),
llm: AppLlmState::new(sel, 2026),
views: AppViewCache { loaded: true, ..Default::default() },
language_distribution: HashMap::new(),
lang_rx: None,
lang_scanning: false,
parse_error_count: 0,
load_state: LoadState::Idle,
load_rx: None,
first_session_date: None,
llm_rx: None,
claude_dir: PathBuf::new(),
}
}
#[test]
fn test_build_summary_clipboard_has_header() {
let app = minimal_app();
let text = app.build_summary_clipboard();
assert!(text.contains("Projects:"));
assert!(text.contains("Sessions:"));
assert!(text.contains("Total Tokens:"));
assert!(text.contains("Project\tTokens\t%"));
}
#[test]
fn test_build_summary_clipboard_includes_projects() {
let mut app = minimal_app();
app.project_dirs = vec![
ProjectDir {
display_name: "my-project".into(),
full_path: "/test/my-project".into(),
session_files: vec![],
path_resolved: true,
},
];
app.views.cached_sessions = vec![
SessionSummary {
project_name: "my-project".into(), model: "opus".into(),
total_tokens: 10_000, input_tokens: 6_000, output_tokens: 3_000,
cache_read_input_tokens: 1_000, cache_creation_input_tokens: 0,
web_search_requests: 0, web_fetch_requests: 0,
tool_call_count: 5,
tool_calls: HashMap::new(),
},
];
let text = app.build_summary_clipboard();
assert!(text.contains("my-project"));
assert!(text.contains("10K"));
}
#[test]
fn test_build_period_clipboard_no_data() {
let app = minimal_app();
let text = app.build_period_clipboard("Month");
assert_eq!(text, "No data loaded.");
}
#[test]
fn test_build_period_clipboard_with_data() {
let mut app = minimal_app();
let mut model_tokens = HashMap::new();
model_tokens.insert("opus".to_string(), 5000);
let mut project_tokens = HashMap::new();
project_tokens.insert("test-proj".to_string(), 5000);
app.views.current_view_data = Some(ViewData {
total_sessions: 3, total_messages: 25, total_tokens: 5000,
model_tokens,
model_token_detail: HashMap::new(),
project_tokens,
daily_activity: vec![],
total_web_search_requests: 0, total_web_fetch_requests: 0,
..Default::default()
});
let text = app.build_period_clipboard("Month");
assert!(text.contains("Sessions: 3"));
assert!(text.contains("Total Tokens: 5K"));
assert!(text.contains("test-proj"));
}
#[test]
fn test_forward_navigation_blocked_at_current_month() {
let mut app = minimal_app();
let today = chrono::Local::now().naive_local().date();
app.selected_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap();
app.active_tab = Tab::Monthly;
app.load_state = LoadState::Idle;
app.navigate_forward();
assert_eq!(app.selected_month.month(), today.month() as u32);
assert_eq!(app.selected_month.year(), today.year());
}
#[test]
fn test_backward_navigation_blocked_at_first_session() {
let mut app = minimal_app();
app.first_session_date = Some(d(2025, 11, 4));
app.selected_month = d(2025, 11, 1);
app.active_tab = Tab::Monthly;
app.load_state = LoadState::Idle;
app.navigate_back();
assert_eq!(app.selected_month.year(), 2025);
assert_eq!(app.selected_month.month(), 11);
}
#[test]
fn test_forward_navigation_blocked_at_current_year() {
let mut app = minimal_app();
let today = chrono::Local::now().naive_local().date();
app.selected_year = today.year();
app.active_tab = Tab::Yearly;
app.load_state = LoadState::Idle;
app.navigate_forward();
assert_eq!(app.selected_year, today.year());
}
#[test]
fn test_backward_navigation_blocked_at_first_year() {
let mut app = minimal_app();
app.first_session_date = Some(d(2025, 6, 15));
app.selected_year = 2025;
app.active_tab = Tab::Yearly;
app.load_state = LoadState::Idle;
app.navigate_back();
assert_eq!(app.selected_year, 2025);
}
#[test]
fn test_navigation_ignored_while_loading() {
let mut app = minimal_app();
app.active_tab = Tab::Monthly;
app.load_state = LoadState::Loading;
let original_month = app.selected_month;
app.navigate_forward();
assert_eq!(app.selected_month, original_month);
app.navigate_back();
assert_eq!(app.selected_month, original_month);
app.switch_tab(Tab::Yearly);
assert_eq!(app.active_tab, Tab::Monthly);
}
#[test]
fn test_app_parses_localized_first_session_date() {
let mut stats = StatsCache::default();
stats.first_session_date = Some("03/09/2026 14:00:30".to_string());
let app = App::new(stats, vec![], vec![], None, PathBuf::new());
assert!(app.first_session_date.is_some());
let d = app.first_session_date.unwrap();
assert_eq!(d.year(), 2026);
assert_eq!(d.month(), 3);
assert_eq!(d.day(), 9);
}
#[test]
fn test_app_parses_iso_first_session_date() {
let mut stats = StatsCache::default();
stats.first_session_date = Some("2025-11-04T08:34:16.259Z".to_string());
let app = App::new(stats, vec![], vec![], None, PathBuf::new());
assert!(app.first_session_date.is_some());
let d = app.first_session_date.unwrap();
assert_eq!(d.year(), 2025);
assert_eq!(d.month(), 11);
assert_eq!(d.day(), 4);
}
#[test]
fn test_focus_section_next_cycles_correctly() {
assert_eq!(FocusSection::ModelBars.next(), FocusSection::LanguageBars);
assert_eq!(FocusSection::LanguageBars.next(), FocusSection::ToolCalls);
assert_eq!(FocusSection::ToolCalls.next(), FocusSection::ProjectTable);
assert_eq!(FocusSection::ProjectTable.next(), FocusSection::ModelBars);
}
#[test]
fn test_focus_section_prev_cycles_correctly() {
assert_eq!(FocusSection::ModelBars.prev(), FocusSection::ProjectTable);
assert_eq!(FocusSection::LanguageBars.prev(), FocusSection::ModelBars);
assert_eq!(FocusSection::ToolCalls.prev(), FocusSection::LanguageBars);
assert_eq!(FocusSection::ProjectTable.prev(), FocusSection::ToolCalls);
}
#[test]
fn test_focus_section_full_cycle() {
let start = FocusSection::ModelBars;
let after_4 = start.next().next().next().next();
assert_eq!(after_4, start);
}
#[test]
fn test_focus_section_full_cycle_reverse() {
let start = FocusSection::ModelBars;
let after_4 = start.prev().prev().prev().prev();
assert_eq!(after_4, start);
}
#[test]
fn test_scroll_focused_up_does_not_underflow() {
let mut app = minimal_app();
app.ui.focus_section = FocusSection::ModelBars;
app.ui.model_scroll = 0;
app.scroll_focused(-1);
assert_eq!(app.ui.model_scroll, 0);
app.scroll_focused(-100);
assert_eq!(app.ui.model_scroll, 0);
}
#[test]
fn test_scroll_focused_down_increments() {
let mut app = minimal_app();
app.ui.focus_section = FocusSection::ModelBars;
app.ui.model_scroll = 0;
app.scroll_focused(1);
assert_eq!(app.ui.model_scroll, 1);
app.scroll_focused(5);
assert_eq!(app.ui.model_scroll, 6);
}
#[test]
fn test_scroll_focused_page_up_down() {
let mut app = minimal_app();
app.ui.focus_section = FocusSection::ToolCalls;
app.ui.tool_scroll = 0;
app.scroll_focused(10); assert_eq!(app.ui.tool_scroll, 10);
app.scroll_focused(-10); assert_eq!(app.ui.tool_scroll, 0);
}
#[test]
fn test_scroll_targets_correct_section() {
let mut app = minimal_app();
app.ui.focus_section = FocusSection::ModelBars;
app.scroll_focused(5);
assert_eq!(app.ui.model_scroll, 5);
assert_eq!(app.ui.lang_scroll, 0);
app.ui.focus_section = FocusSection::LanguageBars;
app.scroll_focused(3);
assert_eq!(app.ui.model_scroll, 5);
assert_eq!(app.ui.lang_scroll, 3);
app.ui.focus_section = FocusSection::ToolCalls;
app.scroll_focused(7);
assert_eq!(app.ui.tool_scroll, 7);
app.ui.focus_section = FocusSection::ProjectTable;
app.scroll_focused(2);
assert_eq!(app.ui.project_scroll, 2);
}
#[test]
fn test_scroll_in_llm_tab_uses_llm_scroll() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.scroll = 0;
app.scroll_focused(5);
assert_eq!(app.llm.scroll, 5);
app.scroll_focused(-2);
assert_eq!(app.llm.scroll, 3);
assert_eq!(app.ui.model_scroll, 0);
}
#[test]
fn test_switch_tab_resets_scrolls() {
let mut app = minimal_app();
app.ui.model_scroll = 5;
app.ui.lang_scroll = 3;
app.ui.tool_scroll = 7;
app.ui.project_scroll = 2;
app.llm.scroll = 10;
app.switch_tab(Tab::Monthly);
assert_eq!(app.ui.model_scroll, 0);
assert_eq!(app.ui.lang_scroll, 0);
assert_eq!(app.ui.tool_scroll, 0);
assert_eq!(app.ui.project_scroll, 0);
assert_eq!(app.llm.scroll, 0);
}
#[test]
fn test_switch_tab_blocked_while_loading() {
let mut app = minimal_app();
app.load_state = LoadState::Loading;
app.active_tab = Tab::Summary;
app.switch_tab(Tab::Monthly);
assert_eq!(app.active_tab, Tab::Summary);
}
#[test]
fn test_switch_tab_blocked_while_llm_loading() {
let mut app = minimal_app();
app.llm.load_state = LoadState::Loading;
app.active_tab = Tab::Summary;
app.switch_tab(Tab::Yearly);
assert_eq!(app.active_tab, Tab::Summary);
}
#[test]
fn test_navigate_forward_monthly() {
let mut app = minimal_app();
app.active_tab = Tab::Monthly;
app.selected_month = d(2026, 1, 1);
app.first_session_date = Some(d(2025, 1, 1));
app.navigate_forward();
assert_eq!(app.selected_month, d(2026, 2, 1));
}
#[test]
fn test_navigate_back_monthly() {
let mut app = minimal_app();
app.active_tab = Tab::Monthly;
app.selected_month = d(2026, 3, 1);
app.first_session_date = Some(d(2025, 1, 1));
app.navigate_back();
assert_eq!(app.selected_month, d(2026, 2, 1));
}
#[test]
fn test_navigate_forward_yearly() {
let mut app = minimal_app();
app.active_tab = Tab::Yearly;
app.selected_year = 2024;
let today = chrono::Local::now().naive_local().date();
app.navigate_forward();
if today.year() >= 2025 {
assert_eq!(app.selected_year, 2025);
}
}
#[test]
fn test_navigate_back_yearly() {
let mut app = minimal_app();
app.active_tab = Tab::Yearly;
app.selected_year = 2026;
app.first_session_date = Some(d(2024, 1, 1));
app.navigate_back();
assert_eq!(app.selected_year, 2025);
}
#[test]
fn test_llm_navigate_forward_monthly() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.report_type = LlmReportType::Monthly;
app.llm.selected_month = d(2026, 1, 1);
app.navigate_forward();
assert_eq!(app.llm.selected_month, d(2026, 2, 1));
}
#[test]
fn test_llm_navigate_back_monthly() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.report_type = LlmReportType::Monthly;
app.llm.selected_month = d(2026, 3, 1);
app.first_session_date = Some(d(2025, 1, 1));
app.navigate_back();
assert_eq!(app.llm.selected_month, d(2026, 2, 1));
}
#[test]
fn test_llm_navigate_forward_yearly() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.report_type = LlmReportType::Yearly;
app.llm.selected_year = 2024;
let today = chrono::Local::now().naive_local().date();
app.navigate_forward();
if today.year() >= 2025 {
assert_eq!(app.llm.selected_year, 2025);
}
}
#[test]
fn test_llm_navigate_back_yearly() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.report_type = LlmReportType::Yearly;
app.llm.selected_year = 2026;
app.first_session_date = Some(d(2024, 1, 1));
app.navigate_back();
assert_eq!(app.llm.selected_year, 2025);
}
#[test]
fn test_llm_navigate_back_blocked_at_first_month() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.report_type = LlmReportType::Monthly;
app.llm.selected_month = d(2025, 11, 1);
app.first_session_date = Some(d(2025, 11, 4));
app.navigate_back();
assert_eq!(app.llm.selected_month, d(2025, 11, 1));
}
#[test]
fn test_llm_navigate_back_blocked_at_first_year() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.report_type = LlmReportType::Yearly;
app.llm.selected_year = 2025;
app.first_session_date = Some(d(2025, 6, 1));
app.navigate_back();
assert_eq!(app.llm.selected_year, 2025);
}
#[test]
fn test_summary_tab_navigation_noop() {
let mut app = minimal_app();
app.active_tab = Tab::Summary;
let original_month = app.selected_month;
let original_year = app.selected_year;
app.navigate_forward();
app.navigate_back();
assert_eq!(app.selected_month, original_month);
assert_eq!(app.selected_year, original_year);
}
#[test]
fn test_refresh_blocked_while_loading() {
let mut app = minimal_app();
app.load_state = LoadState::Loading;
let mut monthly_cache = HashMap::new();
monthly_cache.insert((2026, 5), ViewData::default());
app.views.monthly_cache = monthly_cache;
app.refresh_data();
assert!(!app.views.monthly_cache.is_empty());
}
#[test]
fn test_refresh_blocked_while_llm_loading() {
let mut app = minimal_app();
app.llm.load_state = LoadState::Loading;
let mut yearly_cache = HashMap::new();
yearly_cache.insert(2026, ViewData::default());
app.views.yearly_cache = yearly_cache;
app.refresh_data();
assert!(!app.views.yearly_cache.is_empty());
}
#[test]
fn test_llm_navigate_all_report_type_noop() {
let mut app = minimal_app();
app.active_tab = Tab::LlmSummary;
app.llm.report_type = LlmReportType::All;
let original_month = app.llm.selected_month;
let original_year = app.llm.selected_year;
app.navigate_forward();
app.navigate_back();
assert_eq!(app.llm.selected_month, original_month);
assert_eq!(app.llm.selected_year, original_year);
}
#[test]
fn test_tab_equality() {
assert_eq!(Tab::Summary, Tab::Summary);
assert_eq!(Tab::Monthly, Tab::Monthly);
assert_eq!(Tab::Yearly, Tab::Yearly);
assert_eq!(Tab::LlmSummary, Tab::LlmSummary);
assert_ne!(Tab::Summary, Tab::Monthly);
}
#[test]
fn test_load_state_equality() {
assert_eq!(LoadState::Idle, LoadState::Idle);
assert_eq!(LoadState::Loading, LoadState::Loading);
assert_ne!(LoadState::Idle, LoadState::Loading);
}
}