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}
26
27impl SessionHistoryBrowser {
28    pub async fn new() -> Result<Self> {
29        let db_path = get_database_path()?;
30        let db = Database::new(&db_path)?;
31
32        // Load recent sessions (last 100)
33        let sessions =
34            SessionQueries::list_with_filter(&db.connection, None, None, None, Some(100))?;
35
36        let mut table_state = TableState::default();
37        if !sessions.is_empty() {
38            table_state.select(Some(0));
39        }
40
41        Ok(Self {
42            sessions,
43            table_state,
44            show_filters: false,
45        })
46    }
47
48    pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
49        loop {
50            terminal.draw(|f| self.render(f))?;
51
52            if event::poll(Duration::from_millis(100))? {
53                match event::read()? {
54                    Event::Key(key) if key.kind == KeyEventKind::Press => {
55                        match key.code {
56                            KeyCode::Char('q') | KeyCode::Esc => break,
57                            KeyCode::Up => {
58                                let i = match self.table_state.selected() {
59                                    Some(i) => {
60                                        if i == 0 {
61                                            self.sessions.len() - 1
62                                        } else {
63                                            i - 1
64                                        }
65                                    }
66                                    None => 0,
67                                };
68                                self.table_state.select(Some(i));
69                            }
70                            KeyCode::Down => {
71                                let i = match self.table_state.selected() {
72                                    Some(i) => {
73                                        if i >= self.sessions.len() - 1 {
74                                            0
75                                        } else {
76                                            i + 1
77                                        }
78                                    }
79                                    None => 0,
80                                };
81                                self.table_state.select(Some(i));
82                            }
83                            KeyCode::Char('f') => {
84                                self.show_filters = !self.show_filters;
85                            }
86                            KeyCode::Enter => {
87                                // Could toggle details expansion or similar
88                            }
89                            _ => {}
90                        }
91                    }
92                    _ => {}
93                }
94            }
95        }
96
97        Ok(())
98    }
99
100    fn render(&mut self, f: &mut Frame) {
101        let chunks = Layout::default()
102            .direction(Direction::Vertical)
103            .constraints([
104                Constraint::Length(3), // Filter Bar
105                Constraint::Min(0),    // Main Content (Table + Details)
106                Constraint::Length(1), // Footer
107            ])
108            .split(f.size());
109
110        // 1. Filter Bar
111        self.render_filter_bar(f, chunks[0]);
112
113        // 2. Main Content
114        self.render_main_content(f, chunks[1]);
115
116        // 3. Footer
117        self.render_footer(f, chunks[2]);
118    }
119
120    fn render_filter_bar(&self, f: &mut Frame, area: Rect) {
121        let block = Block::default()
122            .borders(Borders::ALL)
123            .border_style(Style::default().fg(ColorScheme::GRAY_TEXT))
124            .title(" Filters ")
125            .style(Style::default().bg(ColorScheme::CLEAN_BG));
126
127        let filter_text = if self.show_filters {
128            "Project: [All]  Date: [Any]  Duration: [Any]"
129        } else {
130            "Press 'f' to show filters"
131        };
132
133        let p = Paragraph::new(filter_text)
134            .block(block)
135            .style(Style::default().fg(ColorScheme::GRAY_TEXT));
136
137        f.render_widget(p, area);
138    }
139
140    fn render_main_content(&mut self, f: &mut Frame, area: Rect) {
141        let chunks = Layout::default()
142            .direction(Direction::Horizontal)
143            .constraints([
144                Constraint::Percentage(70), // Table
145                Constraint::Percentage(30), // Details
146            ])
147            .split(area);
148
149        // Table
150        self.render_session_table(f, chunks[0]);
151
152        // Details
153        self.render_details_panel(f, chunks[1]);
154    }
155
156    fn render_session_table(&mut self, f: &mut Frame, area: Rect) {
157        let header_cells = ["ID", "Date", "Project", "Duration", "Status"]
158            .iter()
159            .map(|h| {
160                Cell::from(*h).style(
161                    Style::default()
162                        .fg(ColorScheme::GRAY_TEXT)
163                        .add_modifier(Modifier::BOLD),
164                )
165            });
166
167        let header = Row::new(header_cells).height(1).bottom_margin(1);
168
169        let rows = self.sessions.iter().map(|session| {
170            let duration = if let Some(end) = session.end_time {
171                (end - session.start_time).num_seconds() - session.paused_duration.num_seconds()
172            } else {
173                (Utc::now() - session.start_time).num_seconds()
174                    - session.paused_duration.num_seconds()
175            };
176
177            let start_time = session.start_time.with_timezone(&Local);
178            let date_str = start_time.format("%Y-%m-%d").to_string();
179            let duration_str = Formatter::format_duration(duration);
180            let status = if session.end_time.is_none() {
181                "Active"
182            } else {
183                "Done"
184            };
185
186            // Placeholder for project name until we join with projects
187            let project_str = format!("Project {}", session.project_id);
188
189            let cells = vec![
190                Cell::from(session.id.unwrap_or(0).to_string()),
191                Cell::from(date_str),
192                Cell::from(project_str),
193                Cell::from(duration_str),
194                Cell::from(status),
195            ];
196
197            Row::new(cells).height(1)
198        });
199
200        let table = Table::new(rows)
201            .widths(&[
202                Constraint::Length(5),
203                Constraint::Length(12),
204                Constraint::Min(20),
205                Constraint::Length(10),
206                Constraint::Length(8),
207            ])
208            .header(header)
209            .block(Block::default().borders(Borders::ALL).title(" Sessions "))
210            .highlight_style(
211                Style::default()
212                    .bg(ColorScheme::CLEAN_BLUE)
213                    .fg(ColorScheme::CLEAN_BG)
214                    .add_modifier(Modifier::BOLD),
215            );
216
217        f.render_stateful_widget(table, area, &mut self.table_state);
218    }
219
220    fn render_details_panel(&self, f: &mut Frame, area: Rect) {
221        let block = Block::default()
222            .borders(Borders::ALL)
223            .title(" Details ")
224            .style(Style::default().bg(ColorScheme::CLEAN_BG));
225
226        f.render_widget(block.clone(), area);
227
228        let inner_area = block.inner(area);
229
230        if let Some(selected_index) = self.table_state.selected() {
231            if let Some(session) = self.sessions.get(selected_index) {
232                let start_time = session.start_time.with_timezone(&Local);
233                let end_time_str = if let Some(end) = session.end_time {
234                    end.with_timezone(&Local).format("%H:%M:%S").to_string()
235                } else {
236                    "Now".to_string()
237                };
238
239                let details = vec![
240                    Line::from(Span::styled(
241                        "Context:",
242                        Style::default().fg(ColorScheme::GRAY_TEXT),
243                    )),
244                    Line::from(Span::styled(
245                        session.context.to_string(),
246                        Style::default().fg(ColorScheme::WHITE_TEXT),
247                    )),
248                    Line::from(""),
249                    Line::from(Span::styled(
250                        "Start Time:",
251                        Style::default().fg(ColorScheme::GRAY_TEXT),
252                    )),
253                    Line::from(Span::styled(
254                        start_time.format("%H:%M:%S").to_string(),
255                        Style::default().fg(ColorScheme::WHITE_TEXT),
256                    )),
257                    Line::from(""),
258                    Line::from(Span::styled(
259                        "End Time:",
260                        Style::default().fg(ColorScheme::GRAY_TEXT),
261                    )),
262                    Line::from(Span::styled(
263                        end_time_str,
264                        Style::default().fg(ColorScheme::WHITE_TEXT),
265                    )),
266                    Line::from(""),
267                    Line::from(Span::styled(
268                        "Notes:",
269                        Style::default().fg(ColorScheme::GRAY_TEXT),
270                    )),
271                    Line::from(Span::styled(
272                        session.notes.clone().unwrap_or("-".to_string()),
273                        Style::default().fg(ColorScheme::WHITE_TEXT),
274                    )),
275                ];
276
277                f.render_widget(
278                    Paragraph::new(details).wrap(ratatui::widgets::Wrap { trim: true }),
279                    inner_area,
280                );
281            }
282        } else {
283            f.render_widget(
284                Paragraph::new("Select a session to view details")
285                    .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
286                inner_area,
287            );
288        }
289    }
290
291    fn render_footer(&self, f: &mut Frame, area: Rect) {
292        let help_text = "[↑/↓] Navigate  [F] Filter  [Enter] Details  [Q] Quit";
293        f.render_widget(
294            Paragraph::new(help_text)
295                .alignment(Alignment::Center)
296                .style(Style::default().fg(ColorScheme::GRAY_TEXT)),
297            area,
298        );
299    }
300}