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 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 }
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), Constraint::Min(0), Constraint::Length(1), ])
108 .split(f.size());
109
110 self.render_filter_bar(f, chunks[0]);
112
113 self.render_main_content(f, chunks[1]);
115
116 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), Constraint::Percentage(30), ])
147 .split(area);
148
149 self.render_session_table(f, chunks[0]);
151
152 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 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}