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(3), // 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 block = Block::default()
158            .borders(Borders::BOTTOM)
159            .border_style(Style::default().fg(ColorScheme::BORDER_DARK));
160
161        f.render_widget(block, area);
162
163        let chunks = Layout::default()
164            .direction(Direction::Horizontal)
165            .constraints([
166                Constraint::Percentage(50), // Title
167                Constraint::Percentage(50), // User@Host
168            ])
169            .margin(1)
170            .split(area);
171
172        f.render_widget(
173            Paragraph::new("SESSION HISTORY").style(
174                Style::default()
175                    .fg(ColorScheme::TEXT_MAIN)
176                    .add_modifier(Modifier::BOLD),
177            ),
178            chunks[0],
179        );
180
181        f.render_widget(
182            Paragraph::new(self.user_host_string.as_str())
183                .alignment(Alignment::Right)
184                .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
185            chunks[1],
186        );
187    }
188
189    fn render_filter_bar(&self, f: &mut Frame, area: Rect) {
190        let block = Block::default()
191            .borders(Borders::BOTTOM)
192            .border_style(Style::default().fg(ColorScheme::BORDER_DARK))
193            .style(Style::default().bg(ColorScheme::BG_DARK));
194
195        f.render_widget(block, area);
196
197        let layout = Layout::default()
198            .direction(Direction::Horizontal)
199            .constraints([
200                Constraint::Length(20), // Start Date
201                Constraint::Length(20), // End Date
202                Constraint::Length(20), // Project
203                Constraint::Length(20), // Duration
204                Constraint::Min(10),    // Search
205            ])
206            .margin(1)
207            .split(area);
208
209        let filters = [
210            "Start Date: [All]",
211            "End Date: [All]",
212            "Project: [All]",
213            "Duration: [Any]",
214            "Search: [None]",
215        ];
216
217        for (i, filter) in filters.iter().enumerate() {
218            if i < layout.len() {
219                f.render_widget(
220                    Paragraph::new(*filter).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
221                    layout[i],
222                );
223            }
224        }
225    }
226
227    fn render_main_content(&mut self, f: &mut Frame, area: Rect) {
228        let chunks = Layout::default()
229            .direction(Direction::Horizontal)
230            .constraints([
231                Constraint::Percentage(70), // Table
232                Constraint::Percentage(30), // Details
233            ])
234            .split(area);
235
236        // Table
237        self.render_session_table(f, chunks[0]);
238
239        // Details
240        self.render_details_panel(f, chunks[1]);
241    }
242
243    fn render_session_table(&mut self, f: &mut Frame, area: Rect) {
244        let block = Block::default()
245            .borders(Borders::RIGHT)
246            .border_style(Style::default().fg(ColorScheme::BORDER_DARK));
247
248        f.render_widget(block.clone(), area);
249        let inner_area = block.inner(area);
250
251        let header_cells = ["DATE", "PROJECT", "DURATION", "START", "END", "STATUS"]
252            .iter()
253            .map(|h| {
254                Cell::from(*h).style(
255                    Style::default()
256                        .fg(ColorScheme::TEXT_SECONDARY)
257                        .add_modifier(Modifier::BOLD),
258                )
259            });
260
261        let header = Row::new(header_cells).height(1).bottom_margin(1);
262
263        let rows = self.sessions.iter().map(|session| {
264            let duration = if let Some(end) = session.end_time {
265                (end - session.start_time).num_seconds() - session.paused_duration.num_seconds()
266            } else {
267                (Utc::now() - session.start_time).num_seconds()
268                    - session.paused_duration.num_seconds()
269            };
270
271            let start_time = session.start_time.with_timezone(&Local);
272            let date_str = start_time.format("%Y-%m-%d").to_string();
273            let start_str = start_time.format("%H:%M").to_string();
274            let end_str = if let Some(end) = session.end_time {
275                end.with_timezone(&Local).format("%H:%M").to_string()
276            } else {
277                "-".to_string()
278            };
279
280            let duration_str = Formatter::format_duration(duration);
281            let status = if session.end_time.is_none() {
282                "RUNNING"
283            } else {
284                "COMPLETED"
285            };
286
287            // Placeholder for project name until we join with projects
288            let project_str = format!("Project {}", session.project_id);
289
290            let cells = vec![
291                Cell::from(date_str).style(Style::default().fg(ColorScheme::TEXT_MAIN)),
292                Cell::from(project_str).style(
293                    Style::default()
294                        .fg(ColorScheme::TEXT_MAIN)
295                        .add_modifier(Modifier::BOLD),
296                ),
297                Cell::from(duration_str).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
298                Cell::from(start_str).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
299                Cell::from(end_str).style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
300                Cell::from(status).style(Style::default().fg(if session.end_time.is_none() {
301                    ColorScheme::SUCCESS
302                } else {
303                    ColorScheme::CLEAN_GREEN
304                })),
305            ];
306
307            Row::new(cells).height(1)
308        });
309
310        let table = Table::new(rows)
311            .widths(&[
312                Constraint::Percentage(15),
313                Constraint::Percentage(25),
314                Constraint::Percentage(15),
315                Constraint::Percentage(15),
316                Constraint::Percentage(15),
317                Constraint::Percentage(15),
318            ])
319            .header(header)
320            .highlight_style(
321                Style::default()
322                    .bg(ColorScheme::PANEL_DARK)
323                    .add_modifier(Modifier::BOLD),
324            );
325
326        f.render_stateful_widget(table, inner_area, &mut self.table_state);
327    }
328
329    fn render_details_panel(&self, f: &mut Frame, area: Rect) {
330        let block = Block::default()
331            .borders(Borders::NONE)
332            .style(Style::default().bg(ColorScheme::BG_DARK));
333
334        f.render_widget(block.clone(), area);
335        let inner_area = block.inner(area);
336
337        if let Some(selected_index) = self.table_state.selected() {
338            if let Some(session) = self.sessions.get(selected_index) {
339                let layout = Layout::default()
340                    .direction(Direction::Vertical)
341                    .constraints([
342                        Constraint::Length(2), // Header
343                        Constraint::Min(1),    // Content
344                    ])
345                    .split(inner_area);
346
347                f.render_widget(
348                    Paragraph::new("SESSION DETAILS")
349                        .style(
350                            Style::default()
351                                .fg(ColorScheme::TEXT_MAIN)
352                                .add_modifier(Modifier::BOLD),
353                        )
354                        .block(
355                            Block::default()
356                                .borders(Borders::BOTTOM)
357                                .border_style(Style::default().fg(ColorScheme::BORDER_DARK)),
358                        ),
359                    layout[0],
360                );
361
362                let details = vec![
363                    Line::from(Span::styled(
364                        "NOTES",
365                        Style::default()
366                            .fg(ColorScheme::TEXT_SECONDARY)
367                            .add_modifier(Modifier::BOLD),
368                    )),
369                    Line::from(Span::styled(
370                        session.notes.clone().unwrap_or("No notes".to_string()),
371                        Style::default().fg(ColorScheme::TEXT_MAIN),
372                    )),
373                    Line::from(""),
374                    Line::from(Span::styled(
375                        "TAGS",
376                        Style::default()
377                            .fg(ColorScheme::TEXT_SECONDARY)
378                            .add_modifier(Modifier::BOLD),
379                    )),
380                    Line::from(Span::styled(
381                        "coding, rust, ui", // Placeholder
382                        Style::default().fg(ColorScheme::TEXT_MAIN),
383                    )),
384                    Line::from(""),
385                    Line::from(Span::styled(
386                        "CONTEXT",
387                        Style::default()
388                            .fg(ColorScheme::TEXT_SECONDARY)
389                            .add_modifier(Modifier::BOLD),
390                    )),
391                    Line::from(Span::styled(
392                        session.context.to_string(),
393                        Style::default().fg(ColorScheme::TEXT_MAIN),
394                    )),
395                ];
396
397                f.render_widget(
398                    Paragraph::new(details)
399                        .wrap(ratatui::widgets::Wrap { trim: true })
400                        .block(
401                            Block::default().padding(ratatui::widgets::Padding::new(1, 1, 1, 1)),
402                        ),
403                    layout[1],
404                );
405            }
406        } else {
407            f.render_widget(
408                Paragraph::new("Select a session to view details")
409                    .alignment(Alignment::Center)
410                    .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
411                inner_area,
412            );
413        }
414    }
415
416    fn render_footer(&self, f: &mut Frame, area: Rect) {
417        let hints = vec![
418            Span::styled(
419                "[↑/↓]",
420                Style::default()
421                    .fg(ColorScheme::PRIMARY_DASHBOARD)
422                    .add_modifier(Modifier::BOLD),
423            ),
424            Span::raw(" Navigate  "),
425            Span::styled(
426                "[F]",
427                Style::default()
428                    .fg(ColorScheme::PRIMARY_DASHBOARD)
429                    .add_modifier(Modifier::BOLD),
430            ),
431            Span::raw(" Filter  "),
432            Span::styled(
433                "[Enter]",
434                Style::default()
435                    .fg(ColorScheme::PRIMARY_DASHBOARD)
436                    .add_modifier(Modifier::BOLD),
437            ),
438            Span::raw(" Details  "),
439            Span::styled(
440                "[Q]",
441                Style::default()
442                    .fg(ColorScheme::ERROR)
443                    .add_modifier(Modifier::BOLD),
444            ),
445            Span::raw(" Quit"),
446        ];
447
448        f.render_widget(
449            Paragraph::new(Line::from(hints))
450                .alignment(Alignment::Center)
451                .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
452            area,
453        );
454    }
455}