tempo_cli/ui/
history.rs

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        // Load recent sessions (last 100)
34        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        // Get user@machine string
43        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                                // Could toggle details expansion or similar
120                            }
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), // Header
137                Constraint::Length(3), // Filter Bar
138                Constraint::Min(0),    // Main Content (Table + Details)
139                Constraint::Length(1), // Footer
140            ])
141            .split(f.size());
142
143        // 0. Header
144        self.render_header(f, chunks[0]);
145
146        // 1. Filter Bar
147        self.render_filter_bar(f, chunks[1]);
148
149        // 2. Main Content
150        self.render_main_content(f, chunks[2]);
151
152        // 3. Footer
153        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), // Title
161                Constraint::Percentage(50), // User@Host
162            ])
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), // Table
207                Constraint::Percentage(30), // Details
208            ])
209            .split(area);
210
211        // Table
212        self.render_session_table(f, chunks[0]);
213
214        // Details
215        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            // Placeholder for project name until we join with projects
249            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}