1use anyhow::Result;
2use chrono::{Local, DateTime, Utc, TimeZone};
3use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4use ratatui::{
5 backend::Backend,
6 layout::{Alignment, Constraint, Direction, Layout, Rect},
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Gauge, Paragraph, Wrap, List, ListItem, BarChart, Sparkline},
10 Frame, Terminal,
11};
12use std::time::Duration;
13use tokio::runtime::Handle;
14use log::debug;
15
16use crate::{
17 models::{Project, Session},
18 db::{Database, get_database_path},
19 db::queries::ProjectQueries,
20 utils::ipc::{IpcClient, IpcMessage, IpcResponse, get_socket_path, is_daemon_running},
21 ui::formatter::Formatter,
22};
23
24pub struct Dashboard {
25 client: IpcClient,
26 show_project_switcher: bool,
27 available_projects: Vec<Project>,
28 selected_project_index: usize,
29}
30
31impl Dashboard {
32 pub async fn new() -> Result<Self> {
33 let socket_path = get_socket_path()?;
34 let client = if socket_path.exists() {
35 match IpcClient::connect(&socket_path).await {
36 Ok(client) => client,
37 Err(_) => IpcClient::new()?,
38 }
39 } else {
40 IpcClient::new()?
41 };
42
43 Ok(Self {
44 client,
45 show_project_switcher: false,
46 available_projects: Vec::new(),
47 selected_project_index: 0,
48 })
49 }
50
51 pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
52 let mut heartbeat_counter = 0;
53
54 loop {
55 if heartbeat_counter >= 30 {
57 if let Err(e) = self.send_activity_heartbeat().await {
58 debug!("Heartbeat error: {}", e);
60 }
61 heartbeat_counter = 0;
62 }
63 heartbeat_counter += 1;
64
65 let current_session = self.get_current_session().await?;
67 let current_project = if let Some(ref session) = current_session {
68 self.get_project_by_session(session).await?
69 } else {
70 None
71 };
72 let daily_stats = self.get_today_stats().await.unwrap_or((0, 0, 0));
73 let session_metrics = self.get_session_metrics().await.unwrap_or(None);
74
75 terminal.draw(|f| {
76 self.render_dashboard_sync(f, ¤t_session, ¤t_project, &daily_stats, &session_metrics);
77 })?;
78
79 if event::poll(Duration::from_millis(100))? {
81 match event::read()? {
82 Event::Key(key) if key.kind == KeyEventKind::Press => {
83 match key.code {
84 KeyCode::Char('q') | KeyCode::Esc => {
85 if self.show_project_switcher {
86 self.show_project_switcher = false;
87 } else {
88 break;
89 }
90 }
91 KeyCode::Char('p') => {
92 self.toggle_project_switcher().await?;
93 }
94 KeyCode::Up => {
95 if self.show_project_switcher {
96 self.navigate_projects(-1);
97 }
98 }
99 KeyCode::Down => {
100 if self.show_project_switcher {
101 self.navigate_projects(1);
102 }
103 }
104 KeyCode::Enter => {
105 if self.show_project_switcher {
106 self.switch_to_selected_project().await?;
107 }
108 }
109 _ => {}
110 }
111 }
112 _ => {}
113 }
114 }
115 }
116
117 Ok(())
118 }
119
120 fn render_dashboard_sync(
121 &self,
122 f: &mut Frame,
123 current_session: &Option<Session>,
124 current_project: &Option<Project>,
125 daily_stats: &(i64, i64, i64),
126 session_metrics: &Option<crate::utils::ipc::SessionMetrics>,
127 ) {
128 let chunks = Layout::default()
129 .direction(Direction::Vertical)
130 .constraints([
131 Constraint::Length(3), Constraint::Length(10), Constraint::Length(6), Constraint::Length(8), Constraint::Min(0), Constraint::Length(3), ])
138 .split(f.size());
139
140 let title = Paragraph::new("Vibe - Time Tracking Dashboard")
142 .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
143 .alignment(Alignment::Center)
144 .block(Block::default().borders(Borders::ALL));
145 f.render_widget(title, chunks[0]);
146
147 self.render_session_info(f, chunks[1], current_session);
149
150 self.render_project_info(f, chunks[2], current_project);
152
153 self.render_session_metrics(f, chunks[3], session_metrics);
155
156 self.render_statistics_sync(f, chunks[4], daily_stats);
158
159 self.render_help(f, chunks[5]);
161
162 if self.show_project_switcher {
164 self.render_project_switcher(f, f.size());
165 }
166 }
167
168 fn render_session_info(&self, f: &mut Frame, area: Rect, session: &Option<Session>) {
169 let block = Block::default()
170 .title("Current Session")
171 .borders(Borders::ALL)
172 .style(Style::default().fg(Color::White));
173
174 if let Some(session) = session {
175 let now = Local::now();
176 let elapsed_seconds = (now.timestamp() - session.start_time.timestamp())
177 - session.paused_duration.num_seconds();
178
179 let session_chunks = Layout::default()
181 .direction(Direction::Vertical)
182 .constraints([
183 Constraint::Length(6), Constraint::Length(2), ])
186 .split(area);
187
188 let status_text = vec![
190 Line::from(vec![
191 Span::raw("Status: "),
192 Span::styled("● ACTIVE", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
193 ]),
194 Line::from(vec![
195 Span::raw("Started: "),
196 Span::styled(
197 Formatter::format_timestamp(&session.start_time.with_timezone(&Local)),
198 Style::default().fg(Color::White)
199 ),
200 ]),
201 Line::from(vec![
202 Span::raw("Elapsed: "),
203 Span::styled(
204 Formatter::format_duration(elapsed_seconds),
205 Formatter::create_highlight_style()
206 ),
207 ]),
208 Line::from(vec![
209 Span::raw("Context: "),
210 Span::styled(
211 session.context.to_string(),
212 Style::default().fg(Color::Yellow)
213 ),
214 ]),
215 ];
216
217 let session_block = Block::default()
218 .borders(Borders::ALL)
219 .title("Current Session")
220 .style(Style::default().fg(Color::White));
221
222 let paragraph = Paragraph::new(status_text)
223 .block(session_block)
224 .wrap(Wrap { trim: true });
225 f.render_widget(paragraph, session_chunks[0]);
226
227 let progress_ratio = self.calculate_session_progress(elapsed_seconds);
229 let progress_bar = Gauge::default()
230 .block(Block::default()
231 .borders(Borders::ALL)
232 .title("Session Progress")
233 .style(Style::default().fg(Color::Cyan)))
234 .gauge_style(Style::default().fg(Color::Green).bg(Color::Black))
235 .percent((progress_ratio * 100.0) as u16)
236 .label(format!("{} / target: 2h", Formatter::format_duration(elapsed_seconds)));
237 f.render_widget(progress_bar, session_chunks[1]);
238
239 } else {
240 let no_session_text = vec![
241 Line::from(Span::styled(
242 "No active session",
243 Formatter::create_warning_style()
244 )),
245 Line::from(Span::raw("")),
246 Line::from(Span::raw("Use 'tempo start' to begin tracking time")),
247 Line::from(Span::raw("")),
248 Line::from(Span::styled(
249 "🎯 Set your focus and track your productivity",
250 Style::default().fg(Color::Cyan)
251 )),
252 ];
253
254 let paragraph = Paragraph::new(no_session_text)
255 .block(block)
256 .wrap(Wrap { trim: true });
257 f.render_widget(paragraph, area);
258 }
259 }
260
261 fn render_project_info(&self, f: &mut Frame, area: Rect, project: &Option<Project>) {
262 let block = Block::default()
263 .title("Current Project")
264 .borders(Borders::ALL)
265 .style(Style::default().fg(Color::White));
266
267 if let Some(project) = project {
268 let project_text = vec![
269 Line::from(vec![
270 Span::raw("Name: "),
271 Span::styled(project.name.clone(), Formatter::create_highlight_style()),
272 ]),
273 Line::from(vec![
274 Span::raw("Path: "),
275 Span::styled(project.path.to_string_lossy().to_string(), Style::default().fg(Color::Gray)),
276 ]),
277 ];
278
279 let paragraph = Paragraph::new(project_text)
280 .block(block)
281 .wrap(Wrap { trim: true });
282 f.render_widget(paragraph, area);
283 } else {
284 let no_project_text = vec![
285 Line::from(Span::styled(
286 "No active project",
287 Formatter::create_warning_style()
288 )),
289 ];
290
291 let paragraph = Paragraph::new(no_project_text)
292 .block(block)
293 .wrap(Wrap { trim: true });
294 f.render_widget(paragraph, area);
295 }
296 }
297
298 fn render_statistics_sync(&self, f: &mut Frame, area: Rect, daily_stats: &(i64, i64, i64)) {
299 let (sessions_count, total_seconds, avg_seconds) = *daily_stats;
300
301 if sessions_count > 0 {
302 let stats_chunks = Layout::default()
304 .direction(Direction::Horizontal)
305 .constraints([
306 Constraint::Percentage(50), Constraint::Percentage(50), ])
309 .split(area);
310
311 let stats_text = vec![
313 Line::from(vec![
314 Span::raw("📊 Sessions: "),
315 Span::styled(sessions_count.to_string(), Formatter::create_highlight_style()),
316 ]),
317 Line::from(vec![
318 Span::raw("⏱️ Total time: "),
319 Span::styled(
320 Formatter::format_duration(total_seconds),
321 Formatter::create_success_style()
322 ),
323 ]),
324 Line::from(vec![
325 Span::raw("📈 Avg session: "),
326 Span::styled(
327 Formatter::format_duration(avg_seconds),
328 Style::default().fg(Color::Cyan)
329 ),
330 ]),
331 Line::from(vec![
332 Span::raw("🎯 Target: "),
333 Span::styled(
334 format!("{:.0}% complete", (total_seconds as f64 / (8.0 * 3600.0)) * 100.0),
335 if total_seconds > 4 * 3600 { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Yellow) }
336 ),
337 ]),
338 ];
339
340 let text_block = Block::default()
341 .title("Today's Summary")
342 .borders(Borders::ALL)
343 .style(Style::default().fg(Color::White));
344
345 let paragraph = Paragraph::new(stats_text)
346 .block(text_block)
347 .wrap(Wrap { trim: true });
348 f.render_widget(paragraph, stats_chunks[0]);
349
350 let daily_goal_seconds = 8 * 3600; let progress_percentage = ((total_seconds as f64 / daily_goal_seconds as f64) * 100.0).min(100.0);
353
354 let goal_chunks = Layout::default()
355 .direction(Direction::Vertical)
356 .constraints([
357 Constraint::Length(3), Constraint::Min(0), ])
360 .split(stats_chunks[1]);
361
362 let daily_progress = Gauge::default()
363 .block(Block::default()
364 .borders(Borders::ALL)
365 .title("Daily Goal (8h)")
366 .style(Style::default().fg(Color::Cyan)))
367 .gauge_style(Style::default().fg(
368 if progress_percentage >= 100.0 { Color::Green }
369 else if progress_percentage >= 50.0 { Color::Yellow }
370 else { Color::Red }
371 ))
372 .percent(progress_percentage as u16)
373 .label(format!("{:.1}%", progress_percentage));
374 f.render_widget(daily_progress, goal_chunks[0]);
375
376 let activity_placeholder = Paragraph::new(vec![
378 Line::from(Span::styled("📈 Activity Timeline", Style::default().fg(Color::Cyan))),
379 Line::from(Span::raw("▁▂▃▅▇█▇▅▃▂▁ (simulated)")),
380 ])
381 .block(Block::default()
382 .borders(Borders::ALL)
383 .title("Activity Pattern")
384 .style(Style::default().fg(Color::Cyan)))
385 .alignment(Alignment::Center);
386 f.render_widget(activity_placeholder, goal_chunks[1]);
387
388 } else {
389 let no_stats_text = vec![
390 Line::from(Span::styled(
391 "📊 No sessions today",
392 Formatter::create_warning_style()
393 )),
394 Line::from(Span::raw("")),
395 Line::from(Span::raw("🚀 Start your first session to see:")),
396 Line::from(Span::raw(" • Session count and timing")),
397 Line::from(Span::raw(" • Daily goal progress")),
398 Line::from(Span::raw(" • Activity patterns")),
399 Line::from(Span::raw(" • Productivity insights")),
400 ];
401
402 let block = Block::default()
403 .title("Today's Summary")
404 .borders(Borders::ALL)
405 .style(Style::default().fg(Color::White));
406
407 let paragraph = Paragraph::new(no_stats_text)
408 .block(block)
409 .wrap(Wrap { trim: true });
410 f.render_widget(paragraph, area);
411 }
412 }
413
414 fn render_session_metrics(&self, f: &mut Frame, area: Rect, metrics: &Option<crate::utils::ipc::SessionMetrics>) {
415 if let Some(metrics) = metrics {
416 let metrics_chunks = Layout::default()
418 .direction(Direction::Horizontal)
419 .constraints([
420 Constraint::Percentage(60), Constraint::Percentage(40), ])
423 .split(area);
424
425 let activity_color = match metrics.activity_score {
427 s if s > 0.7 => Color::Green,
428 s if s > 0.3 => Color::Yellow,
429 _ => Color::Red,
430 };
431
432 let activity_indicator = match metrics.activity_score {
433 s if s > 0.8 => "🔥 Very Active",
434 s if s > 0.6 => "⚡ Active",
435 s if s > 0.3 => "⏳ Moderate",
436 _ => "😴 Low Activity",
437 };
438
439 let metrics_text = vec![
440 Line::from(vec![
441 Span::raw("Activity: "),
442 Span::styled(activity_indicator, Style::default().fg(activity_color)),
443 ]),
444 Line::from(vec![
445 Span::raw("Score: "),
446 Span::styled(
447 format!("{:.1}%", metrics.activity_score * 100.0),
448 Style::default().fg(activity_color)
449 ),
450 ]),
451 Line::from(vec![
452 Span::raw("Active: "),
453 Span::styled(
454 Formatter::format_duration(metrics.active_duration),
455 Formatter::create_highlight_style()
456 ),
457 ]),
458 Line::from(vec![
459 Span::raw("Paused: "),
460 Span::styled(
461 Formatter::format_duration(metrics.paused_duration),
462 Style::default().fg(Color::Gray)
463 ),
464 ]),
465 Line::from(vec![
466 Span::raw("Efficiency: "),
467 Span::styled(
468 format!("{:.0}%", self.calculate_efficiency_percentage(metrics)),
469 Style::default().fg(if self.calculate_efficiency_percentage(metrics) > 70.0 { Color::Green } else { Color::Yellow })
470 ),
471 ]),
472 ];
473
474 let text_block = Block::default()
475 .title("Real-time Metrics")
476 .borders(Borders::ALL)
477 .style(Style::default().fg(Color::White));
478
479 let paragraph = Paragraph::new(metrics_text)
480 .block(text_block)
481 .wrap(Wrap { trim: true });
482 f.render_widget(paragraph, metrics_chunks[0]);
483
484 let activity_chunks = Layout::default()
486 .direction(Direction::Vertical)
487 .constraints([
488 Constraint::Length(3), Constraint::Length(3), ])
491 .split(metrics_chunks[1]);
492
493 let activity_gauge = Gauge::default()
495 .block(Block::default()
496 .borders(Borders::ALL)
497 .title("Activity")
498 .style(Style::default().fg(Color::Cyan)))
499 .gauge_style(Style::default().fg(activity_color))
500 .percent((metrics.activity_score * 100.0) as u16)
501 .label(format!("{:.0}%", metrics.activity_score * 100.0));
502 f.render_widget(activity_gauge, activity_chunks[0]);
503
504 let efficiency = self.calculate_efficiency_percentage(metrics);
506 let efficiency_color = if efficiency > 80.0 { Color::Green }
507 else if efficiency > 60.0 { Color::Yellow }
508 else { Color::Red };
509
510 let efficiency_gauge = Gauge::default()
511 .block(Block::default()
512 .borders(Borders::ALL)
513 .title("Efficiency")
514 .style(Style::default().fg(Color::Cyan)))
515 .gauge_style(Style::default().fg(efficiency_color))
516 .percent(efficiency as u16)
517 .label(format!("{:.0}%", efficiency));
518 f.render_widget(efficiency_gauge, activity_chunks[1]);
519
520 } else {
521 let no_metrics_block = Block::default()
522 .title("Real-time Metrics")
523 .borders(Borders::ALL)
524 .style(Style::default().fg(Color::White));
525
526 let no_metrics_text = vec![
527 Line::from(Span::styled(
528 "No active session",
529 Formatter::create_warning_style()
530 )),
531 Line::from(Span::raw("")),
532 Line::from(Span::raw("Start tracking to see:")),
533 Line::from(Span::raw("• Activity indicators")),
534 Line::from(Span::raw("• Efficiency metrics")),
535 Line::from(Span::raw("• Visual progress")),
536 ];
537
538 let paragraph = Paragraph::new(no_metrics_text)
539 .block(no_metrics_block)
540 .wrap(Wrap { trim: true });
541 f.render_widget(paragraph, area);
542 }
543 }
544
545 fn render_help(&self, f: &mut Frame, area: Rect) {
546 let help_text = if self.show_project_switcher {
547 "Project Switcher: ↑/↓ Navigate | Enter - Select | P/Esc - Close"
548 } else {
549 "Press 'q' or 'Esc' to quit | 'p' for project switcher | Updates every 100ms"
550 };
551
552 let help_paragraph = Paragraph::new(help_text)
553 .style(Style::default().fg(Color::Gray))
554 .alignment(Alignment::Center)
555 .block(Block::default().borders(Borders::ALL));
556 f.render_widget(help_paragraph, area);
557 }
558
559 fn render_project_switcher(&self, f: &mut Frame, area: Rect) {
560 let popup_area = self.centered_rect(60, 70, area);
562
563 let background = Block::default()
565 .style(Style::default().bg(Color::Black))
566 .borders(Borders::ALL)
567 .title("🔄 Project Switcher")
568 .title_alignment(Alignment::Center)
569 .border_style(Style::default().fg(Color::Cyan));
570 f.render_widget(background, popup_area);
571
572 let projects_area = Layout::default()
574 .direction(Direction::Vertical)
575 .margin(1)
576 .split(popup_area)[0];
577
578 if self.available_projects.is_empty() {
579 let no_projects = Paragraph::new("No projects found\n\nCreate a project first using:\nvibe init <project-name>")
580 .style(Style::default().fg(Color::Yellow))
581 .alignment(Alignment::Center)
582 .wrap(Wrap { trim: true });
583 f.render_widget(no_projects, projects_area);
584 } else {
585 let project_items: Vec<ListItem> = self.available_projects
586 .iter()
587 .enumerate()
588 .map(|(i, project)| {
589 let style = if i == self.selected_project_index {
590 Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
591 } else {
592 Style::default().fg(Color::White)
593 };
594
595 let content = vec![
596 Line::from(vec![
597 Span::styled(format!("{}", project.name), style),
598 ]),
599 Line::from(vec![
600 Span::styled(
601 format!(" 📁 {}", project.path.to_string_lossy()),
602 Style::default().fg(if i == self.selected_project_index { Color::DarkGray } else { Color::Gray })
603 ),
604 ]),
605 ];
606
607 ListItem::new(content).style(style)
608 })
609 .collect();
610
611 let projects_list = List::new(project_items)
612 .style(Style::default().fg(Color::White));
613 f.render_widget(projects_list, projects_area);
614 }
615 }
616
617 fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
618 let popup_layout = Layout::default()
619 .direction(Direction::Vertical)
620 .constraints([
621 Constraint::Percentage((100 - percent_y) / 2),
622 Constraint::Percentage(percent_y),
623 Constraint::Percentage((100 - percent_y) / 2),
624 ])
625 .split(r);
626
627 Layout::default()
628 .direction(Direction::Horizontal)
629 .constraints([
630 Constraint::Percentage((100 - percent_x) / 2),
631 Constraint::Percentage(percent_x),
632 Constraint::Percentage((100 - percent_x) / 2),
633 ])
634 .split(popup_layout[1])[1]
635 }
636
637 async fn get_current_session(&mut self) -> Result<Option<Session>> {
638 if !is_daemon_running() {
639 return Ok(None);
640 }
641
642 let response = self.client.send_message(&IpcMessage::GetActiveSession).await?;
643 match response {
644 IpcResponse::ActiveSession(session) => Ok(session),
645 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get active session: {}", e)),
646 _ => Ok(None),
647 }
648 }
649
650 async fn get_project_by_session(&mut self, session: &Session) -> Result<Option<Project>> {
651 if !is_daemon_running() {
652 return Ok(None);
653 }
654
655 let response = self.client.send_message(&IpcMessage::GetProject(session.project_id)).await?;
656 match response {
657 IpcResponse::Project(project) => Ok(project),
658 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get project: {}", e)),
659 _ => Ok(None),
660 }
661 }
662
663 async fn get_today_stats(&mut self) -> Result<(i64, i64, i64)> { if !is_daemon_running() {
665 return Ok((0, 0, 0));
666 }
667
668 let today = chrono::Local::now().date_naive();
669 let response = self.client.send_message(&IpcMessage::GetDailyStats(today)).await?;
670 match response {
671 IpcResponse::DailyStats { sessions_count, total_seconds, avg_seconds } => {
672 Ok((sessions_count, total_seconds, avg_seconds))
673 },
674 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to get daily stats: {}", e)),
675 _ => Ok((0, 0, 0)),
676 }
677 }
678
679 async fn get_session_metrics(&mut self) -> Result<Option<crate::utils::ipc::SessionMetrics>> {
680 if !is_daemon_running() {
681 return Ok(None);
682 }
683
684 let response = self.client.send_message(&IpcMessage::GetSessionMetrics(0)).await?;
685 match response {
686 IpcResponse::SessionMetrics(metrics) => Ok(Some(metrics)),
687 IpcResponse::Error(_) => Ok(None), _ => Ok(None),
689 }
690 }
691
692 async fn send_activity_heartbeat(&mut self) -> Result<()> {
693 if !is_daemon_running() {
694 return Ok(());
695 }
696
697 let _response = self.client.send_message(&IpcMessage::ActivityHeartbeat).await?;
698 Ok(())
699 }
700
701 fn calculate_session_progress(&self, elapsed_seconds: i64) -> f64 {
702 let target_seconds = 2 * 3600; (elapsed_seconds as f64 / target_seconds as f64).min(1.0)
705 }
706
707 fn calculate_efficiency_percentage(&self, metrics: &crate::utils::ipc::SessionMetrics) -> f64 {
708 if metrics.total_duration == 0 {
709 return 0.0;
710 }
711
712 let efficiency = (metrics.active_duration as f64 / metrics.total_duration as f64) * 100.0;
713 efficiency.min(100.0)
714 }
715
716 async fn toggle_project_switcher(&mut self) -> Result<()> {
717 if self.show_project_switcher {
718 self.show_project_switcher = false;
719 } else {
720 self.available_projects = self.load_projects().await?;
722 self.selected_project_index = 0;
723 self.show_project_switcher = true;
724 }
725 Ok(())
726 }
727
728 fn navigate_projects(&mut self, direction: i32) {
729 if !self.available_projects.is_empty() {
730 let current = self.selected_project_index as i32;
731 let new_index = (current + direction).max(0).min(self.available_projects.len() as i32 - 1);
732 self.selected_project_index = new_index as usize;
733 }
734 }
735
736 async fn switch_to_selected_project(&mut self) -> Result<()> {
737 if let Some(selected_project) = self.available_projects.get(self.selected_project_index) {
738 let project_id = selected_project.id.unwrap_or(0);
740 let response = self.client.send_message(&IpcMessage::SwitchProject(project_id)).await?;
741 match response {
742 IpcResponse::Success => {
743 self.show_project_switcher = false;
744 },
745 IpcResponse::Error(e) => return Err(anyhow::anyhow!("Failed to switch project: {}", e)),
746 _ => return Err(anyhow::anyhow!("Unexpected response")),
747 }
748 }
749 Ok(())
750 }
751
752 async fn load_projects(&mut self) -> Result<Vec<Project>> {
753 let db_path = get_database_path()?;
754 let db = Database::new(&db_path)?;
755
756 let projects = ProjectQueries::list_all(&db.connection, false)?; Ok(projects)
758 }
759}
760
761fn should_quit(event: Event) -> bool {
762 match event {
763 Event::Key(key) if key.kind == KeyEventKind::Press => {
764 matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
765 }
766 _ => false,
767 }
768}