1use anyhow::Result;
2use chrono::{Local, Utc};
3use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4use ratatui::{
5 backend::Backend,
6 layout::{Alignment, Constraint, Direction, Layout, Rect},
7 style::{Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
10 Frame, Terminal,
11};
12use std::time::Duration;
13
14use crate::{
15 db::queries::SessionQueries,
16 db::{get_database_path, Database},
17 models::Session,
18 ui::{formatter::Formatter, widgets::ColorScheme},
19};
20
21pub struct SessionHistoryBrowser {
22 sessions: Vec<Session>,
23 table_state: TableState,
24 show_filters: bool,
25 user_host_string: String,
26}
27
28impl SessionHistoryBrowser {
29 pub async fn new() -> Result<Self> {
30 let db_path = get_database_path()?;
31 let db = Database::new(&db_path)?;
32
33 let sessions =
35 SessionQueries::list_with_filter(&db.connection, None, None, None, Some(100))?;
36
37 let mut table_state = TableState::default();
38 if !sessions.is_empty() {
39 table_state.select(Some(0));
40 }
41
42 let user = std::process::Command::new("git")
44 .args(["config", "user.name"])
45 .output()
46 .ok()
47 .and_then(|output| {
48 if output.status.success() {
49 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
50 } else {
51 None
52 }
53 })
54 .or_else(|| std::env::var("USER").ok())
55 .unwrap_or_else(|| "user".to_string());
56
57 let host = std::process::Command::new("hostname")
58 .output()
59 .ok()
60 .and_then(|output| {
61 if output.status.success() {
62 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
63 } else {
64 None
65 }
66 })
67 .or_else(|| std::env::var("HOSTNAME").ok())
68 .unwrap_or_else(|| "machine".to_string());
69
70 let user_host_string = format!("{}@{}", user, host);
71
72 Ok(Self {
73 sessions,
74 table_state,
75 show_filters: false,
76 user_host_string,
77 })
78 }
79
80 pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
81 loop {
82 terminal.draw(|f| self.render(f))?;
83
84 if event::poll(Duration::from_millis(100))? {
85 match event::read()? {
86 Event::Key(key) if key.kind == KeyEventKind::Press => {
87 match key.code {
88 KeyCode::Char('q') | KeyCode::Esc => break,
89 KeyCode::Up => {
90 let i = match self.table_state.selected() {
91 Some(i) => {
92 if i == 0 {
93 self.sessions.len() - 1
94 } else {
95 i - 1
96 }
97 }
98 None => 0,
99 };
100 self.table_state.select(Some(i));
101 }
102 KeyCode::Down => {
103 let i = match self.table_state.selected() {
104 Some(i) => {
105 if i >= self.sessions.len() - 1 {
106 0
107 } else {
108 i + 1
109 }
110 }
111 None => 0,
112 };
113 self.table_state.select(Some(i));
114 }
115 KeyCode::Char('f') => {
116 self.show_filters = !self.show_filters;
117 }
118 KeyCode::Enter => {
119 }
121 _ => {}
122 }
123 }
124 _ => {}
125 }
126 }
127 }
128
129 Ok(())
130 }
131
132 fn render(&mut self, f: &mut Frame) {
133 let chunks = Layout::default()
134 .direction(Direction::Vertical)
135 .constraints([
136 Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
141 .split(f.size());
142
143 self.render_header(f, chunks[0]);
145
146 self.render_filter_bar(f, chunks[1]);
148
149 self.render_main_content(f, chunks[2]);
151
152 self.render_footer(f, chunks[3]);
154 }
155
156 fn render_header(&self, f: &mut Frame, area: Rect) {
157 let chunks = Layout::default()
158 .direction(Direction::Horizontal)
159 .constraints([
160 Constraint::Percentage(50), Constraint::Percentage(50), ])
163 .split(area);
164
165 f.render_widget(
166 Paragraph::new("Tempo TUI :: History Browser").style(
167 Style::default()
168 .fg(ColorScheme::WHITE_TEXT)
169 .add_modifier(Modifier::BOLD),
170 ),
171 chunks[0],
172 );
173
174 f.render_widget(
175 Paragraph::new(self.user_host_string.as_str())
176 .alignment(Alignment::Right)
177 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
178 chunks[1],
179 );
180 }
181
182 fn render_filter_bar(&self, f: &mut Frame, area: Rect) {
183 let block = Block::default()
184 .borders(Borders::ALL)
185 .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
186 .title(" Filters ")
187 .style(Style::default().bg(ColorScheme::CLEAN_BG));
188
189 let filter_text = if self.show_filters {
190 "Project: [All] Date: [Any] Duration: [Any]"
191 } else {
192 "Press 'f' to show filters"
193 };
194
195 let p = Paragraph::new(filter_text)
196 .block(block)
197 .style(Style::default().fg(ColorScheme::GRAY_TEXT));
198
199 f.render_widget(p, area);
200 }
201
202 fn render_main_content(&mut self, f: &mut Frame, area: Rect) {
203 let chunks = Layout::default()
204 .direction(Direction::Horizontal)
205 .constraints([
206 Constraint::Percentage(70), Constraint::Percentage(30), ])
209 .split(area);
210
211 self.render_session_table(f, chunks[0]);
213
214 self.render_details_panel(f, chunks[1]);
216 }
217
218 fn render_session_table(&mut self, f: &mut Frame, area: Rect) {
219 let header_cells = ["ID", "Date", "Project", "Duration", "Status"]
220 .iter()
221 .map(|h| {
222 Cell::from(*h).style(
223 Style::default()
224 .fg(ColorScheme::GRAY_TEXT)
225 .add_modifier(Modifier::BOLD),
226 )
227 });
228
229 let header = Row::new(header_cells).height(1).bottom_margin(1);
230
231 let rows = self.sessions.iter().map(|session| {
232 let duration = if let Some(end) = session.end_time {
233 (end - session.start_time).num_seconds() - session.paused_duration.num_seconds()
234 } else {
235 (Utc::now() - session.start_time).num_seconds()
236 - session.paused_duration.num_seconds()
237 };
238
239 let start_time = session.start_time.with_timezone(&Local);
240 let date_str = start_time.format("%Y-%m-%d").to_string();
241 let duration_str = Formatter::format_duration(duration);
242 let status = if session.end_time.is_none() {
243 "Active"
244 } else {
245 "Done"
246 };
247
248 let project_str = format!("Project {}", session.project_id);
250
251 let cells = vec![
252 Cell::from(session.id.unwrap_or(0).to_string()),
253 Cell::from(date_str),
254 Cell::from(project_str),
255 Cell::from(duration_str),
256 Cell::from(status),
257 ];
258
259 Row::new(cells).height(1)
260 });
261
262 let table = Table::new(rows)
263 .widths(&[
264 Constraint::Length(5),
265 Constraint::Length(12),
266 Constraint::Min(20),
267 Constraint::Length(10),
268 Constraint::Length(8),
269 ])
270 .header(header)
271 .block(Block::default().borders(Borders::ALL).title(" Sessions "))
272 .highlight_style(
273 Style::default()
274 .bg(ColorScheme::CLEAN_BLUE)
275 .fg(ColorScheme::CLEAN_BG)
276 .add_modifier(Modifier::BOLD),
277 );
278
279 f.render_stateful_widget(table, area, &mut self.table_state);
280 }
281
282 fn render_details_panel(&self, f: &mut Frame, area: Rect) {
283 let block = Block::default()
284 .borders(Borders::ALL)
285 .title(" Details ")
286 .style(Style::default().bg(ColorScheme::CLEAN_BG));
287
288 f.render_widget(block.clone(), area);
289
290 let inner_area = block.inner(area);
291
292 if let Some(selected_index) = self.table_state.selected() {
293 if let Some(session) = self.sessions.get(selected_index) {
294 let start_time = session.start_time.with_timezone(&Local);
295 let end_time_str = if let Some(end) = session.end_time {
296 end.with_timezone(&Local).format("%H:%M:%S").to_string()
297 } else {
298 "Now".to_string()
299 };
300
301 let details = vec![
302 Line::from(Span::styled(
303 "Context:",
304 Style::default().fg(ColorScheme::GRAY_TEXT),
305 )),
306 Line::from(Span::styled(
307 session.context.to_string(),
308 Style::default().fg(ColorScheme::WHITE_TEXT),
309 )),
310 Line::from(""),
311 Line::from(Span::styled(
312 "Start Time:",
313 Style::default().fg(ColorScheme::GRAY_TEXT),
314 )),
315 Line::from(Span::styled(
316 start_time.format("%H:%M:%S").to_string(),
317 Style::default().fg(ColorScheme::WHITE_TEXT),
318 )),
319 Line::from(""),
320 Line::from(Span::styled(
321 "End Time:",
322 Style::default().fg(ColorScheme::GRAY_TEXT),
323 )),
324 Line::from(Span::styled(
325 end_time_str,
326 Style::default().fg(ColorScheme::WHITE_TEXT),
327 )),
328 Line::from(""),
329 Line::from(Span::styled(
330 "Notes:",
331 Style::default().fg(ColorScheme::GRAY_TEXT),
332 )),
333 Line::from(Span::styled(
334 session.notes.clone().unwrap_or("-".to_string()),
335 Style::default().fg(ColorScheme::WHITE_TEXT),
336 )),
337 ];
338
339 f.render_widget(
340 Paragraph::new(details).wrap(ratatui::widgets::Wrap { trim: true }),
341 inner_area,
342 );
343 }
344 } else {
345 f.render_widget(
346 Paragraph::new("Select a session to view details")
347 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
348 inner_area,
349 );
350 }
351 }
352
353 fn render_footer(&self, f: &mut Frame, area: Rect) {
354 let help_text = "[↑/↓] Navigate [F] Filter [Enter] Details [Q] Quit";
355 f.render_widget(
356 Paragraph::new(help_text)
357 .alignment(Alignment::Center)
358 .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
359 area,
360 );
361 }
362}