From ae1b5e2b536dd7ba829e4cd2e1529049f8a1ca4c Mon Sep 17 00:00:00 2001 From: David Hagerty Date: Sat, 24 Jan 2026 07:51:37 -0800 Subject: [PATCH] feat(tui): implement dashboard with kanban and activity views --- src/tui/app.rs | 10 +++ src/tui/mod.rs | 1 + src/tui/ui.rs | 36 +++++---- src/tui/views/dashboard.rs | 153 +++++++++++++++++++++++++++++++++++++ src/tui/views/mod.rs | 3 + 5 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 src/tui/views/dashboard.rs create mode 100644 src/tui/views/mod.rs diff --git a/src/tui/app.rs b/src/tui/app.rs index 284e38f..e519958 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,5 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crate::tui::views::{DashboardState, DashboardMode}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActiveTab { Dashboard, @@ -10,6 +12,7 @@ pub enum ActiveTab { pub struct App { pub running: bool, pub active_tab: ActiveTab, + pub dashboard: DashboardState, } impl App { @@ -17,6 +20,7 @@ impl App { Self { running: true, active_tab: ActiveTab::Dashboard, + dashboard: DashboardState::new(), } } @@ -35,6 +39,12 @@ impl App { ActiveTab::Execution => ActiveTab::Dashboard, }; } + (KeyCode::Char('k'), KeyModifiers::NONE) if self.active_tab == ActiveTab::Dashboard => { + self.dashboard.mode = DashboardMode::Kanban; + } + (KeyCode::Char('a'), KeyModifiers::NONE) if self.active_tab == ActiveTab::Dashboard => { + self.dashboard.mode = DashboardMode::Activity; + } _ => {} } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 13a8cc7..886cab8 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,5 +1,6 @@ mod app; mod ui; +pub mod views; pub mod widgets; pub use app::{App, ActiveTab}; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 4937bd4..c315401 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -7,6 +7,7 @@ use ratatui::{ }; use crate::tui::{App, ActiveTab}; +use crate::tui::views::draw_dashboard; use crate::tui::widgets::TabBar; pub fn draw(frame: &mut Frame, app: &App) { @@ -23,22 +24,29 @@ pub fn draw(frame: &mut Frame, app: &App) { frame.render_widget(TabBar::new(app.active_tab), chunks[0]); // Main content area - let content_block = Block::default() - .borders(Borders::ALL) - .title(match app.active_tab { - ActiveTab::Dashboard => " Dashboard ", - ActiveTab::Planning => " Planning ", - ActiveTab::Execution => " Execution ", - }); + match app.active_tab { + ActiveTab::Dashboard => { + draw_dashboard(frame, chunks[1], &app.dashboard); + } + _ => { + let content_block = Block::default() + .borders(Borders::ALL) + .title(match app.active_tab { + ActiveTab::Dashboard => " Dashboard ", + ActiveTab::Planning => " Planning ", + ActiveTab::Execution => " Execution ", + }); - let placeholder = Paragraph::new(match app.active_tab { - ActiveTab::Dashboard => "Dashboard view - coming soon", - ActiveTab::Planning => "Planning view - coming soon", - ActiveTab::Execution => "Execution view - coming soon", - }) - .block(content_block); + let placeholder = Paragraph::new(match app.active_tab { + ActiveTab::Dashboard => "Dashboard view - coming soon", + ActiveTab::Planning => "Planning view - coming soon", + ActiveTab::Execution => "Execution view - coming soon", + }) + .block(content_block); - frame.render_widget(placeholder, chunks[1]); + frame.render_widget(placeholder, chunks[1]); + } + } // Status bar let status = Line::from(vec![ diff --git a/src/tui/views/dashboard.rs b/src/tui/views/dashboard.rs new file mode 100644 index 0000000..1eb4544 --- /dev/null +++ b/src/tui/views/dashboard.rs @@ -0,0 +1,153 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DashboardMode { + Kanban, + Activity, +} + +pub struct DashboardState { + pub mode: DashboardMode, + pub specs: Vec, + pub selected_column: usize, + pub selected_row: usize, +} + +#[derive(Debug, Clone)] +pub struct SpecSummary { + pub name: String, + pub status: SpecStatus, + pub task_progress: (usize, usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpecStatus { + Draft, + Ready, + Running, + Completed, +} + +impl DashboardState { + pub fn new() -> Self { + Self { + mode: DashboardMode::Kanban, + specs: Vec::new(), + selected_column: 0, + selected_row: 0, + } + } +} + +impl Default for DashboardState { + fn default() -> Self { + Self::new() + } +} + +pub fn draw_dashboard(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(area); + + let mode_text = match state.mode { + DashboardMode::Kanban => " View: [K]anban │ Activity ", + DashboardMode::Activity => " View: Kanban │ [A]ctivity ", + }; + let mode_bar = Paragraph::new(mode_text) + .style(Style::default().fg(Color::Cyan)); + frame.render_widget(mode_bar, chunks[0]); + + match state.mode { + DashboardMode::Kanban => draw_kanban(frame, chunks[1], state), + DashboardMode::Activity => draw_activity(frame, chunks[1], state), + } +} + +fn draw_kanban(frame: &mut ratatui::Frame, area: Rect, state: &DashboardState) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(area); + + let column_titles = ["Draft", "Ready", "Running", "Completed"]; + let statuses = [SpecStatus::Draft, SpecStatus::Ready, SpecStatus::Running, SpecStatus::Completed]; + + for (i, (col_area, (title, status))) in columns.iter() + .zip(column_titles.iter().zip(statuses.iter())) + .enumerate() + { + let is_selected = i == state.selected_column; + let style = if is_selected { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(*title) + .border_style(style); + + let specs_in_column: Vec<&SpecSummary> = state.specs + .iter() + .filter(|s| s.status == *status) + .collect(); + + let items: Vec = specs_in_column + .iter() + .enumerate() + .map(|(j, spec)| { + let content = format!("{} ({}/{})", spec.name, spec.task_progress.0, spec.task_progress.1); + let item_style = if is_selected && j == state.selected_row { + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(content).style(item_style) + }) + .collect(); + + let list = List::new(items).block(block); + frame.render_widget(list, *col_area); + } +} + +fn draw_activity(frame: &mut ratatui::Frame, area: Rect, _state: &DashboardState) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Activity Feed "); + + let header = Line::from(vec![ + Span::styled("Time ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("│ ", Style::default().fg(Color::DarkGray)), + Span::styled("Event ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("│ ", Style::default().fg(Color::DarkGray)), + Span::styled("Spec ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("│ ", Style::default().fg(Color::DarkGray)), + Span::styled("Details", Style::default().add_modifier(Modifier::BOLD)), + ]); + + let content = Paragraph::new(vec![ + header, + Line::from("─".repeat(area.width.saturating_sub(2) as usize)), + Line::from(" No activity yet"), + ]) + .block(block); + + frame.render_widget(content, area); +} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs new file mode 100644 index 0000000..dffde22 --- /dev/null +++ b/src/tui/views/mod.rs @@ -0,0 +1,3 @@ +mod dashboard; + +pub use dashboard::{DashboardState, DashboardMode, SpecSummary, SpecStatus, draw_dashboard}; -- 2.51.2