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(3), 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 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), Constraint::Percentage(50), ])
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), Constraint::Length(20), Constraint::Length(20), Constraint::Length(20), Constraint::Min(10), ])
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), Constraint::Percentage(30), ])
234 .split(area);
235
236 self.render_session_table(f, chunks[0]);
238
239 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 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), Constraint::Min(1), ])
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", 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}