diff --git a/TASK.md b/TASK.md index f959b213..0b544714 100644 --- a/TASK.md +++ b/TASK.md @@ -1,159 +1,86 @@ -# Journal Implementation Plan +# Home Dashboard Implementation Task ## Overview -The journal functionality will serve as a calendar-like system that tracks and displays phases from user ingestions. Unlike traditional calendars, this system needs to handle events (phases) with uncertain start and end times, making it more complex than standard calendar implementations. +Create a focused terminal-based dashboard that displays active substance ingestions and their analysis data. The dashboard will use existing components to show current state and near-future projections of substance effects. ## Core Components -### 1. Journal Entity -```rust -struct JournalEntry { - id: i32, - ingestion_id: i32, - phase_id: String, - expected_start_time: DateTime, - expected_end_time: Option>, - confidence_start: f32, // 0.0-1.0 representing certainty of start time - confidence_end: f32, // 0.0-1.0 representing certainty of end time - actual_start_time: Option>, - actual_end_time: Option>, - status: PhaseStatus, - metadata: Json, // Additional phase-specific data - created_at: DateTime, - updated_at: DateTime, -} -``` - -### 2. Event Handling System -- Create event handlers for `IngestionCreated` -- Automatically generate phase entries when new ingestions are created -- Update journal entries as phases progress -- Handle phase transitions and completion states - -### 3. Time Uncertainty Management -- Implement confidence scoring system for start/end times -- Use substance-specific metabolism data to estimate phase durations -- Account for individual user variations and conditions -- Provide visual indicators for time uncertainty in UI - -## Implementation Steps - -1. Database Schema - - Create journal entries table - - Add foreign key relationships to ingestions - - Add indices for efficient time-based queries - -2. Core Logic - - Implement phase calculation system - - Create event handlers for ingestion creation - - Develop time uncertainty algorithms - - Build phase transition logic - -3. Query System - - Create efficient queries for time ranges - - Implement filtering by confidence levels - - Add support for phase-specific queries - - Build aggregation queries for analysis - -4. API/CLI Interface - - Add journal-specific commands - - Implement time range queries - - Create phase filtering options - - Add export capabilities - -5. UI Components - - Design calendar view with uncertainty visualization - - Implement phase timeline display - - Add filtering and search interface - - Create detailed phase view - -## Technical Considerations - -### Database Migrations -```sql -CREATE TABLE journal_entry ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ingestion_id INTEGER NOT NULL, - phase_id TEXT NOT NULL, - expected_start_time TIMESTAMP NOT NULL, - expected_end_time TIMESTAMP, - confidence_start REAL NOT NULL, - confidence_end REAL NOT NULL, - actual_start_time TIMESTAMP, - actual_end_time TIMESTAMP, - status TEXT NOT NULL, - metadata JSON NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - FOREIGN KEY (ingestion_id) REFERENCES ingestion(id) -); - -CREATE INDEX idx_journal_entry_times ON journal_entry(expected_start_time, expected_end_time); -CREATE INDEX idx_journal_entry_ingestion ON journal_entry(ingestion_id); -``` - -### Event System Integration -```rust -// Event handler for new ingestions -async fn handle_ingestion_created(event: IngestionCreated) -> Result<()> { - let phases = calculate_phases_for_ingestion(event.ingestion_id).await?; - - for phase in phases { - create_journal_entry(JournalEntry { - ingestion_id: event.ingestion_id, - phase_id: phase.id, - expected_start_time: phase.estimated_start, - expected_end_time: phase.estimated_end, - confidence_start: calculate_start_confidence(&phase), - confidence_end: calculate_end_confidence(&phase), - status: PhaseStatus::Pending, - // ... other fields - }).await?; - } - Ok(()) -} -``` - -## Future Enhancements - -1. Machine Learning Integration - - Train models on user data to improve time predictions - - Develop personalized confidence scoring - - Implement pattern recognition for phase transitions - -2. Advanced Visualization - - Heat maps for time uncertainty - - Phase overlap visualization - - Interactive timeline adjustments - -3. Integration Features - - Calendar export (iCal format) - - Mobile app synchronization - - External API access - -4. Analysis Tools - - Phase pattern analysis - - Substance interaction tracking - - Long-term trend visualization +### 1. Active Substances Panel (`render_active_stacks`) +- Display list of current active ingestions: + - Substance name + - Dosage amount and unit + - Time since ingestion (calculated from ingested_at) + - Status indicators based on IngestionAnalysis data + - Warning highlight for substances nearing effect end +- Sort by ingestion time +- Auto-refresh when data changes + +### 2. Effect Timeline (`render_timeline`) +- Implementation using DashboardCharts: + - X-axis: Current time to +12h window + - Y-axis: Effect intensity + - Plot points from IngestionAnalysis data + - Current time marker + - Individual substance effect curves + - Combined effect visualization +- Update frequency: Real-time with data changes + +### 3. Performance Metrics (`render_performance_metrics`) +- Real-time gauges showing: + - Focus Level (derived from IngestionAnalysis cognitive metrics) + - Energy Level (calculated from active stimulant effects) + - Cognitive Load (aggregate from active substances) +- Values derived from IngestionAnalysis data +- Update when underlying data changes +- Warning indicators for high threshold values + +### 4. Statistics Bar (`render_stats_bar`) +- Key metrics: + - Active substances count + - Unique substances count + - Total active dosages + - Next effect phase change prediction + - Substances in decline phase count + +## Technical Requirements + +### Data Integration +- Primary data source: IngestionAnalysis model +- Real-time updates when ingestion data changes +- Efficient data processing for display +- Proper handling of missing or incomplete analysis data + + +### UI Implementation +- Utilize existing render functions: + - render_stats_bar + - render_active_stacks + - render_performance_metrics + - render_timeline +- Ensure proper error handling for missing data +- Maintain responsive UI updates +- Consistent color scheme for status indicators + +### Performance Goals +- Update latency under 100ms +- Efficient memory usage +- Smooth UI transitions +- Optimized refresh cycles ## Success Criteria - -1. Accurate Phase Tracking - - Correctly generate phases for new ingestions - - Accurate time predictions within confidence bounds - - Proper handling of phase transitions - -2. Performance - - Fast query response times (< 100ms) - - Efficient handling of large datasets - - Minimal impact on ingestion creation - -3. Usability - - Intuitive calendar interface - - Clear visualization of uncertainty - - Easy filtering and search capabilities - -4. Reliability - - Consistent phase calculations - - Proper error handling - - Data integrity maintenance \ No newline at end of file +1. Data Accuracy + - All components correctly reflect IngestionAnalysis data + - Proper calculation of derived metrics + - Accurate time-based calculations + +2. User Interface + - Clear information hierarchy + - Readable data presentation + - Responsive layout + - Effective warning indicators + +3. Technical Performance + - Real-time updates working correctly + - Smooth UI transitions + - Proper error handling for edge cases + - Efficient resource utilization diff --git a/src/ingestion/query.rs b/src/ingestion/query.rs index 304d097b..aa37ac31 100644 --- a/src/ingestion/query.rs +++ b/src/ingestion/query.rs @@ -4,11 +4,11 @@ use crate::utils::DATABASE_CONNECTION; use async_trait::async_trait; use clap::Parser; use miette::IntoDiagnostic; -use sea_orm::prelude::async_trait; -use sea_orm::prelude::*; use sea_orm::EntityTrait; use sea_orm::QueryOrder; use sea_orm::QuerySelect; +use sea_orm::prelude::async_trait; +use sea_orm::prelude::*; use sea_orm_migration::IntoSchemaManagerConnection; use typed_builder::TypedBuilder; diff --git a/src/journal/tests/mod.rs b/src/journal/tests/mod.rs new file mode 100644 index 00000000..2039214b --- /dev/null +++ b/src/journal/tests/mod.rs @@ -0,0 +1 @@ +mod events_test; \ No newline at end of file diff --git a/src/tui/app.rs b/src/tui/app.rs index 0a182633..b8952ce5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,3 +1,8 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::core::QueryHandler; +use crate::ingestion::ListIngestions; +use crate::ingestion::model::Ingestion; +use crate::substance::repository; use crate::tui::core::Renderable; use crate::tui::events::AppEvent; use crate::tui::events::AppMessage; @@ -9,15 +14,20 @@ use crate::tui::layout::header::Header; use crate::tui::layout::header::Message; use crate::tui::layout::help::Help; use crate::tui::theme::Theme; +use crate::tui::views::Home; +use crate::tui::views::Welcome; use crate::tui::views::ingestion::create_ingestion::CreateIngestionState; use crate::tui::views::ingestion::get_ingestion::IngestionViewState; use crate::tui::views::ingestion::list_ingestion::IngestionListState; use crate::tui::views::loading::LoadingScreen; -use crate::tui::views::Welcome; use crate::tui::widgets::EventHandler as WidgetEventHandler; use crate::tui::widgets::Focusable; use crate::tui::widgets::Navigable; use crate::tui::widgets::Stateful; +use crate::tui::widgets::active_ingestions::ActiveIngestionPanel; +use crate::tui::widgets::dashboard_charts::DashboardCharts; +use crate::tui::widgets::timeline_sidebar::TimelineSidebar; +use crate::utils::DATABASE_CONNECTION; use async_std::task; use async_std::task::JoinHandle; use crossterm::event as crossterm_event; @@ -26,26 +36,26 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::MouseEvent; use crossterm::execute; -use crossterm::terminal::disable_raw_mode; -use crossterm::terminal::enable_raw_mode; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; +use crossterm::terminal::disable_raw_mode; +use crossterm::terminal::enable_raw_mode; use futures::executor::block_on; use futures::future::Future; use futures::future::FutureExt; use miette::IntoDiagnostic; use miette::Result; use ratatui::prelude::*; -use ratatui::widgets::block::Title; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Clear; use ratatui::widgets::Gauge; use ratatui::widgets::Paragraph; +use ratatui::widgets::block::Title; use std::collections::HashMap; -use std::io::stdout; use std::io::Stdout; +use std::io::stdout; use std::time::Duration; use std::time::Instant; use tracing::debug; @@ -68,6 +78,9 @@ pub struct Application loading_screen: Option, background_tasks: HashMap>, bool)>, data_cache: HashMap, + active_ingestions: Vec<(Ingestion, Option)>, + welcome_ticks: u8, + dashboard_charts: DashboardCharts, } impl Application @@ -80,7 +93,7 @@ impl Application let mut app = Self { terminal, event_handler: EventHandler::new(), - current_screen: Screen::Welcome, + current_screen: Screen::Welcome, // Start with Welcome last_tick: Instant::now(), ingestion_details: IngestionViewState::new(), ingestion_list: IngestionListState::new(), @@ -89,13 +102,18 @@ impl Application status_bar: Footer::new(), help_page: Help::new(), show_help: false, - target_screen: None, + target_screen: Some(Screen::Home), // Set Home as target loading_screen: None, background_tasks: HashMap::new(), data_cache: HashMap::new(), + active_ingestions: Vec::new(), + welcome_ticks: 0, + dashboard_charts: DashboardCharts::new(), }; - app.update_screen(app.current_screen).await?; + app.update_active_ingestions().await?; + app.update_screen(Screen::Welcome).await?; + Ok(app) } @@ -219,7 +237,7 @@ impl Application .constraints([ Constraint::Length(3), Constraint::Min(10), - Constraint::Length(3), + Constraint::Max(3), ]) .split(area); @@ -239,14 +257,43 @@ impl Application | Screen::Welcome => { let block = Block::default() - .title("Home") + .title("Welcome") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let welcome_area = block.inner(chunks[1]); + frame.render_widget(block, chunks[1]); + Welcome::default().render(welcome_area, frame).unwrap(); + } + | Screen::Home => + { + let block = Block::default() + .title("Dashboard") .borders(Borders::ALL) .border_type(BorderType::Rounded) .style(Style::default().bg(Theme::SURFACE0)); let home_area = block.inner(chunks[1]); frame.render_widget(block, chunks[1]); - Welcome::default().render(home_area, frame).unwrap(); + + // Create horizontal layout for dashboard and timeline + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]) + .spacing(1) + .split(home_area); + + // Render dashboard charts + let _ = self.dashboard_charts.render(content_chunks[0], frame); + + // Render timeline sidebar + let mut timeline = TimelineSidebar::new(); + timeline.update(self.active_ingestions.clone()); + let _ = timeline.render(content_chunks[1], frame); } | Screen::CreateIngestion => { @@ -336,6 +383,52 @@ impl Application Ok(()) } + async fn update_active_ingestions(&mut self) -> Result<()> + { + if !self.should_refresh_data("active_ingestions") + { + return Ok(()); + } + + let ingestions = ListIngestions::default().query().await?; + let mut analyzed_ingestions = Vec::new(); + let mut active_analyses = Vec::new(); + + for ingestion in ingestions + { + let substance = + repository::get_substance(&ingestion.substance, &DATABASE_CONNECTION).await?; + + if let Some(substance_data) = substance + { + if let Ok(analysis) = + IngestionAnalysis::analyze(ingestion.clone(), substance_data).await + { + let now = chrono::Local::now(); + if now >= analysis.ingestion_start && now <= analysis.ingestion_end + { + analyzed_ingestions.push((ingestion, Some(analysis.clone()))); + active_analyses.push(analysis); + } + } + } + } + + self.active_ingestions = analyzed_ingestions; + self.dashboard_charts.set_active_ingestions(active_analyses.clone()); + + self.data_cache + .insert("active_ingestions".to_string(), Instant::now()); + + // Also update the header and status bar + self.header + .update(Message::SetScreen(self.current_screen))?; + self.status_bar + .update(StatusBarMsg::UpdateScreen(self.current_screen))?; + + Ok(()) + } + async fn check_background_tasks(&mut self) -> Result<()> { let task_keys: Vec = self.background_tasks.keys().cloned().collect(); @@ -383,6 +476,9 @@ impl Application self.background_tasks .retain(|_, (_, completed)| !*completed); + // Update active ingestions + self.update_active_ingestions().await?; + Ok(()) } @@ -396,6 +492,17 @@ impl Application self.render()?; self.check_background_tasks().await?; + // Handle Welcome screen transition + if self.current_screen == Screen::Welcome + { + self.welcome_ticks += 1; + if self.welcome_ticks >= 4 + { + // About 1 second with 250ms tick rate + self.update_screen(Screen::Home).await?; + } + } + if crossterm_event::poll(Duration::from_millis(250)).into_diagnostic()? { let event = crossterm_event::read().into_diagnostic()?; @@ -403,6 +510,12 @@ impl Application // Handle quit key, but not when editing in the create ingestion form if let Event::Key(key_event) = event { + // Prevent navigation to Welcome screen + if key_event.code == KeyCode::Char('1') + && self.current_screen == Screen::Welcome + { + continue; + } if key_event.code == KeyCode::Char('q') { match self.current_screen @@ -494,42 +607,6 @@ impl Application { | Screen::ListIngestions => match key.code { - | KeyCode::Char('j') | KeyCode::Down => - { - self.ingestion_list.next(); - } - | KeyCode::Char('k') | KeyCode::Up => - { - self.ingestion_list.previous(); - } - | KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => - { - if let Some(ingestion) = - self.ingestion_list.selected_ingestion() - { - if let Some(id) = ingestion.id - { - block_on(async { - self.ingestion_details - .load_ingestion(id.to_string()) - .await - .map_err(|e| { - miette::miette!( - "Failed to load ingestion: {}", - e - ) - }) - })?; - self.update_screen(Screen::ViewIngestion).await?; - } - } - } - | KeyCode::Char('n') => - { - self.update_screen(Screen::CreateIngestion).await?; - } - | _ => - {} | KeyCode::Char('j') | KeyCode::Down => { self.ingestion_list.next(); @@ -547,13 +624,7 @@ impl Application { self.ingestion_details .load_ingestion(id.to_string()) - .await - .map_err(|e| { - miette::miette!( - "Failed to load ingestion: {}", - e - ) - })?; + .await?; self.update_screen(Screen::ViewIngestion).await?; } } @@ -576,25 +647,6 @@ impl Application }, | Screen::CreateIngestion => { - // Handle create ingestion form events - if let Some(msg) = - self.create_ingestion.handle_event(AppEvent::Key(key))? - { - self.update(msg).await?; - } - } - | Screen::ViewIngestion => match key.code - { - | KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => - { - self.update_screen(Screen::ListIngestions).await?; - } - | _ => - {} - }, - | Screen::CreateIngestion => - { - // Handle create ingestion form events if let Some(msg) = self.create_ingestion.handle_event(AppEvent::Key(key))? { @@ -631,11 +683,9 @@ impl Application {} | AppMessage::NavigateToPage(screen) => { - // After creating an ingestion, we want to refresh the list if screen == Screen::ListIngestions && self.current_screen == Screen::CreateIngestion { - // Force a refresh by removing from cache self.data_cache.remove("ingestion_list"); } self.update_screen(screen).await?; @@ -652,21 +702,8 @@ impl Application | _ => {} }, - | AppMessage::SelectNext => match self.current_screen - { - | Screen::ListIngestions => self.ingestion_list.next(), - | _ => - {} - }, - | AppMessage::SelectPrevious => match self.current_screen - { - | Screen::ListIngestions => self.ingestion_list.previous(), - | _ => - {} - }, | AppMessage::LoadData => { - // Force a refresh by removing from cache self.data_cache.remove("ingestion_list"); self.update_screen(Screen::ListIngestions).await?; } @@ -674,12 +711,16 @@ impl Application { self.update_screen(Screen::CreateIngestion).await?; } - | AppMessage::CreateIngestion => + | AppMessage::Refresh => { - self.update_screen(Screen::CreateIngestion).await?; + self.data_cache.clear(); + self.update_active_ingestions().await?; + self.update_screen(self.current_screen).await?; + } + | AppMessage::ListIngestions => + { + self.update_screen(Screen::ListIngestions).await?; } - | _ => - {} } Ok(()) diff --git a/src/tui/events.rs b/src/tui/events.rs index fabb70f0..5f9ad1f3 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -49,6 +49,7 @@ pub enum Screen { #[default] Welcome, + Home, ListIngestions, Loading, CreateIngestion, @@ -70,4 +71,4 @@ impl EventHandler pub fn push(&mut self, event: AppEvent) { self.events.push(event); } pub fn clear(&mut self) { self.events.clear(); } -} +} \ No newline at end of file diff --git a/src/tui/layout/footer.rs b/src/tui/layout/footer.rs index e2f65e31..d6e4f44e 100644 --- a/src/tui/layout/footer.rs +++ b/src/tui/layout/footer.rs @@ -136,6 +136,19 @@ impl Footer let help_spans = match self.current_screen { | Screen::Welcome => vec![ + Span::styled("h", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Home ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("?", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Help ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), + Span::styled("q", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Quit ", Style::default().fg(Theme::TEXT)), + ], + | Screen::Home => vec![ + Span::styled("2", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" Ingestions ", Style::default().fg(Theme::TEXT)), + Span::styled("│", Style::default().fg(Theme::OVERLAY1)), Span::styled("?", Style::default().fg(Theme::OVERLAY0)), Span::styled(" Help ", Style::default().fg(Theme::TEXT)), Span::styled("│", Style::default().fg(Theme::OVERLAY1)), @@ -210,6 +223,7 @@ impl Footer match self.current_screen { | Screen::Welcome => "Welcome", + | Screen::Home => "Home", | Screen::ListIngestions => "Ingestions", | Screen::CreateIngestion => "Create Ingestion", | Screen::ViewIngestion => "Ingestion", @@ -353,4 +367,4 @@ impl Focusable for Footer impl Default for Footer { fn default() -> Self { Self::new() } -} +} \ No newline at end of file diff --git a/src/tui/layout/header.rs b/src/tui/layout/header.rs index a5e89895..4a176c0e 100644 --- a/src/tui/layout/header.rs +++ b/src/tui/layout/header.rs @@ -73,7 +73,7 @@ impl Renderable for Header let nav_items = Line::from(vec![ Span::raw(" "), - if matches!(self.current_screen, Screen::Welcome) + if matches!(self.current_screen, Screen::Home) { Span::styled("Home", Style::default().fg(Theme::BASE).bg(Theme::TEXT)) } @@ -81,7 +81,7 @@ impl Renderable for Header { Span::styled("1", Style::default().fg(Theme::OVERLAY0)) }, - if !matches!(self.current_screen, Screen::Welcome) + if !matches!(self.current_screen, Screen::Home) { Span::styled(" Home", Style::default().fg(Theme::TEXT)) } @@ -158,7 +158,9 @@ impl EventHandler for Header { | KeyCode::Char('1') => { - return Ok(Some(Message::SetScreen(Screen::Welcome))); + if self.current_screen != Screen::Welcome { + return Ok(Some(Message::SetScreen(Screen::Home))); + } } | KeyCode::Char('2') => { @@ -199,4 +201,4 @@ impl Focusable for Header { fn is_focused(&self) -> bool { self.focused } fn set_focus(&mut self, focused: bool) { self.focused = focused; } -} +} \ No newline at end of file diff --git a/src/tui/views/home.rs b/src/tui/views/home.rs new file mode 100644 index 00000000..22d3b109 --- /dev/null +++ b/src/tui/views/home.rs @@ -0,0 +1,178 @@ +use crate::tui::core::Renderable; +use crate::tui::Theme; +use crate::tui::widgets::{DashboardCharts, TimelineSidebar}; +use crate::substance::route_of_administration::phase::PhaseClassification; +use chrono::Local; +use miette::Result; +use ratatui::layout::*; +use ratatui::prelude::*; +use ratatui::widgets::*; +use ratatui::Frame; + +pub struct Home<'a> { + active_ingestions: &'a [(crate::ingestion::model::Ingestion, Option)], +} + +impl<'a> Home<'a> { + pub fn new(active_ingestions: &'a [(crate::ingestion::model::Ingestion, Option)]) -> Self { + Self { + active_ingestions, + } + } +} + +impl<'a> Renderable for Home<'a> { + fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> { + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + ]) + .margin(1) + .split(area); + + let stats_block = Block::default() + .title("Overview") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let stats_inner = stats_block.inner(main_chunks[0]); + frame.render_widget(stats_block, main_chunks[0]); + self.render_stats_bar(frame, stats_inner); + + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]) + .spacing(1) + .split(main_chunks[1]); + + self.render_dashboard(frame, content_chunks[0])?; + self.render_timeline_sidebar(frame, content_chunks[1])?; + + Ok(()) + } +} + +impl<'a> Home<'a> { + fn render_stats_bar(&self, frame: &mut Frame, area: Rect) { + let stats = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(20), // Active count + Constraint::Percentage(20), // Unique substances + Constraint::Percentage(20), // Total dosages + Constraint::Percentage(20), // Next phase change + Constraint::Percentage(20), // Declining substances + ]) + .split(area); + + let active_count = self.active_ingestions.len(); + let unique_substances = self.active_ingestions.iter() + .map(|(i, _)| &i.substance) + .collect::>() + .len(); + + let total_dosages: f64 = self.active_ingestions.iter() + .map(|(i, _)| i.dosage.as_base_units()) + .sum(); + + let declining_count = self.active_ingestions.iter() + .filter(|(_, a)| { + a.as_ref().map_or(false, |analysis| { + analysis.current_phase == Some(PhaseClassification::Comedown) + }) + }) + .count(); + + // Find next phase change + let next_phase_change = self.active_ingestions.iter() + .filter_map(|(_, a)| a.as_ref()) + .filter_map(|analysis| { + analysis.phases.iter() + .find(|phase| { + let now = Local::now(); + phase.duration_range.end > now + }) + .map(|phase| phase.duration_range.end) + }) + .min() + .map_or("None".to_string(), |time| { + let duration = time - Local::now(); + format!("{}m", duration.num_minutes()) + }); + + let stats_style = Style::default().fg(Theme::TEXT); + let value_style = Style::default().fg(Theme::MAUVE).add_modifier(Modifier::BOLD); + + // Render all stats + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Active: ", stats_style), + Span::styled(active_count.to_string(), value_style), + ])).alignment(Alignment::Center), + stats[0] + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Unique: ", stats_style), + Span::styled(unique_substances.to_string(), value_style), + ])).alignment(Alignment::Center), + stats[1] + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Total: ", stats_style), + Span::styled(format!("{:.1}mg", total_dosages), value_style), + ])).alignment(Alignment::Center), + stats[2] + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Next: ", stats_style), + Span::styled(next_phase_change, value_style), + ])).alignment(Alignment::Center), + stats[3] + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Declining: ", stats_style), + Span::styled(declining_count.to_string(), value_style), + ])).alignment(Alignment::Center), + stats[4] + ); + } + + fn render_dashboard(&self, frame: &mut Frame, area: Rect) -> miette::Result<()> { + let block = Block::default() + .title("Dashboard") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut charts = DashboardCharts::new(); + charts.update(self.active_ingestions.to_vec()); + charts.render(inner, frame)?; + + Ok(()) + } + + fn render_timeline_sidebar(&self, frame: &mut Frame, area: Rect) -> miette::Result<()> { + let mut timeline = TimelineSidebar::new(); + timeline.update(self.active_ingestions.to_vec()); + timeline.render(area, frame)?; + + Ok(()) + } +} \ No newline at end of file diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index 9643e458..5199706b 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -1,5 +1,9 @@ +pub mod home; pub mod ingestion; pub mod loading; pub mod welcome; -pub use welcome::Welcome; +pub use home::Home; +pub use ingestion::*; +pub use loading::LoadingScreen; +pub use welcome::Welcome; \ No newline at end of file diff --git a/src/tui/views/welcome.rs b/src/tui/views/welcome.rs index c3665bbe..ed887836 100644 --- a/src/tui/views/welcome.rs +++ b/src/tui/views/welcome.rs @@ -1,15 +1,8 @@ use crate::tui::core::Renderable; use crate::tui::Theme; -use ratatui::layout::Alignment; -use ratatui::layout::Constraint; -use ratatui::layout::Direction; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::prelude::Line; -use ratatui::prelude::Span; -use ratatui::prelude::Style; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; +use ratatui::layout::*; +use ratatui::prelude::*; +use ratatui::widgets::*; use ratatui::Frame; const LOGO: &str = r#" @@ -21,48 +14,50 @@ const LOGO: &str = r#" ░ ▒░ ▒ ▒ ░░ ▒░ ░░▒▓▒ ▒ ▒ ░ ▒▓ ░▒▓░░ ▒░▒░▒░ ░ ▒░ ▒ ▒ ░░ ▒░ ░▒ ▒▒ ▓▒ "#; -#[derive(Default)] -pub struct Welcome {} +pub struct Welcome; -impl Renderable for Welcome -{ - fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> - { - if frame.size().width < 80 - { - let msg = "Please increase your terminal size to at least 80 characters wide to view \ - the Neuronek TUI"; - let msg_area = centered_rect(60, 5, area); +impl Default for Welcome { + fn default() -> Self { + Self + } +} +impl Renderable for Welcome { + fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> { + if frame.size().width < 80 { + let msg = "Please increase your terminal size to at least 80 characters wide"; + let msg_area = centered_rect(60, 5, area); frame.render_widget(Clear, area); frame.render_widget( - Paragraph::new(msg) - .style(Style::default()) - .alignment(Alignment::Center), + Paragraph::new(msg).alignment(Alignment::Center), msg_area, ); - return Ok(()); } let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) - .constraints( - [ - Constraint::Length(8), - Constraint::Length(1), - Constraint::Length(5), - ] - .as_ref(), - ) + .constraints([ + Constraint::Length(8), // Logo + Constraint::Length(1), // Spacing + Constraint::Length(6), // Navigation help + ]) .split(area); + // Logo let logo = Paragraph::new(LOGO) .style(Style::default().fg(Theme::MAUVE)) .alignment(Alignment::Center); + frame.render_widget(logo, chunks[0]); + // Navigation help let welcome_text = vec![ + Line::from(vec![ + Span::styled("Press ", Style::default().fg(Theme::TEXT)), + Span::styled("1", Style::default().fg(Theme::OVERLAY0)), + Span::styled(" to see dashboard", Style::default().fg(Theme::TEXT)), + ]), Line::from(vec![ Span::styled("Press ", Style::default().fg(Theme::TEXT)), Span::styled("2", Style::default().fg(Theme::OVERLAY0)), @@ -84,17 +79,14 @@ impl Renderable for Welcome .style(Style::default()) .alignment(Alignment::Center); - frame.render_widget(logo, chunks[0]); frame.render_widget(help, chunks[2]); Ok(()) } } -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect -{ +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { let top_pad = (area.height - height) / 2; let left_pad = (area.width - width) / 2; - Rect::new(area.left() + left_pad, area.top() + top_pad, width, height) -} +} \ No newline at end of file diff --git a/src/tui/widgets/active_ingestions.rs b/src/tui/widgets/active_ingestions.rs new file mode 100644 index 00000000..e7a5ac7a --- /dev/null +++ b/src/tui/widgets/active_ingestions.rs @@ -0,0 +1,85 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::tui::core::Renderable; +use crate::tui::theme::Theme; +use ratatui::prelude::*; +use ratatui::widgets::{Block, BorderType, Borders, Gauge, List, ListItem, TableState}; + +pub struct ActiveIngestionPanel { + pub ingestions: Vec, + pub state: TableState, +} + +impl ActiveIngestionPanel { + pub fn new() -> Self { + Self { + ingestions: Vec::new(), + state: TableState::default(), + } + } + + pub fn update(&mut self, ingestions: Vec) { + self.ingestions = ingestions; + if self.state.selected().is_none() && !self.ingestions.is_empty() { + self.state.select(Some(0)); + } + } +} + +impl Renderable for ActiveIngestionPanel { + fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> { + let block = Block::default() + .title("Active Ingestions") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + if self.ingestions.is_empty() { + frame.render_widget( + List::new(vec![ListItem::new("No active ingestions")]) + .style(Style::default().fg(Theme::TEXT)), + inner_area, + ); + return Ok(()); + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + self.ingestions + .iter() + .map(|_| Constraint::Length(3)) + .collect::>(), + ) + .split(inner_area); + + for (analysis, chunk) in self.ingestions.iter().zip(chunks.iter()) { + let substance_name = analysis + .substance + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("Unknown"); + + let phase_text = analysis + .current_phase + .map(|p| format!("Phase: {}", p)) + .unwrap_or_else(|| "No phase".to_string()); + + let progress = analysis.progress(); + let progress_text = format!("{:.1}%", progress * 100.0); + + let title = format!("{} - {}", substance_name, phase_text); + let gauge = Gauge::default() + .block(Block::default().title(title)) + .gauge_style(Style::default().fg(Theme::GREEN)) + .label(progress_text) + .ratio(progress); + + frame.render_widget(gauge, *chunk); + } + + Ok(()) + } +} \ No newline at end of file diff --git a/src/tui/widgets/dashboard_charts.rs b/src/tui/widgets/dashboard_charts.rs new file mode 100644 index 00000000..3a39213b --- /dev/null +++ b/src/tui/widgets/dashboard_charts.rs @@ -0,0 +1,341 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::ingestion::model::Ingestion; +use crate::substance::route_of_administration::phase::PhaseClassification; +use crate::tui::core::Renderable; +use crate::tui::theme::Theme; +use chrono::DateTime; +use chrono::Duration; +use chrono::Local; +use chrono::Timelike; +use ratatui::Frame; +use ratatui::layout::Constraint; +use ratatui::layout::Direction; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::symbols; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Chart; +use ratatui::widgets::Dataset; +use ratatui::widgets::GraphType; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Sparkline; +use std::collections::HashMap; + +pub struct DashboardCharts +{ + active_ingestions: Vec, + effect_datasets: Vec<(PhaseClassification, Vec<(f64, f64)>)>, + combined_effects: Vec<(f64, f64)>, + time_marker: Vec<(f64, f64)>, +} + +impl DashboardCharts +{ + pub fn new() -> Self + { + Self { + active_ingestions: Vec::new(), + effect_datasets: Vec::new(), + combined_effects: Vec::new(), + time_marker: Vec::new(), + } + } + + pub fn set_active_ingestions(&mut self, ingestions: Vec) + { + self.active_ingestions = ingestions; + } + + + fn generate_phase_points(&self, range: &std::ops::Range, intensity: f64) + -> Vec<(f64, f64)> + { + let points_count = ((range.end - range.start) * 12.0).ceil() as usize; + (0..=points_count) + .map(|i| { + let progress = i as f64 / points_count as f64; + let hour = range.start + (range.end - range.start) * progress; + + let transition_intensity = if progress < 0.2 + { + intensity * (progress / 0.2) + } + else if progress > 0.8 + { + intensity * ((1.0 - progress) / 0.2) + } + else + { + intensity + }; + + (hour, transition_intensity) + }) + .collect() + } + + fn calculate_hourly_intensity(&self) -> Vec + { + let now = Local::now(); + let start_hour = now - Duration::hours(24); + let mut hourly_data = vec![0; 24]; + + for analysis in &self.active_ingestions + { + let start = analysis.ingestion_start.max(start_hour); + let end = analysis.ingestion_end.min(now); + + if start < end + { + let mut current = start; + while current < end + { + let hour_idx = 23 - (now - current).num_hours() as usize; + if hour_idx < 24 + { + hourly_data[hour_idx] += 1; + } + current = current + Duration::hours(1); + } + } + } + + hourly_data + } + + pub fn update(&mut self, ingestions: Vec<(Ingestion, Option)>) + { + self.active_ingestions = ingestions + .into_iter() + .filter_map(|(_, analysis)| analysis) + .collect(); + self.calculate_effects(); + self.calculate_timeline(); + } + + fn calculate_effects(&mut self) + { + let now = Local::now(); + let day_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap(); + + self.effect_datasets.clear(); + let mut combined = vec![0.0; 24]; + + // Calculate individual effects + for analysis in &self.active_ingestions + { + let effect_data: Vec<(f64, f64)> = (0..24) + .map(|hour| { + let time = day_start + chrono::Duration::hours(hour); + let intensity = if time >= analysis.ingestion_start.naive_local() + && time <= analysis.ingestion_end.naive_local() + { + let phase_intensity = match analysis.current_phase + { + | Some(PhaseClassification::Peak) => 1.0, + | Some(PhaseClassification::Comeup) => 0.7, + | Some(PhaseClassification::Comedown) => 0.4, + | Some(PhaseClassification::Afterglow) => 0.2, + | Some(PhaseClassification::Onset) => 0.3, + | _ => 0.0, + }; + combined[hour as usize] += phase_intensity; + phase_intensity + } + else + { + 0.0 + }; + (hour as f64, intensity) + }) + .collect(); + let phase_data = ( + analysis.current_phase.unwrap_or(PhaseClassification::Onset), + effect_data, + ); + self.effect_datasets.push(phase_data); + } + + // Store combined effects + self.combined_effects = combined + .iter() + .enumerate() + .map(|(hour, &intensity)| (hour as f64, intensity)) + .collect(); + + // Update time marker + let current_hour = now.hour() as f64; + self.time_marker = vec![ + (current_hour, 0.0), + (current_hour, combined[now.hour() as usize]), + ]; + } + + fn calculate_timeline(&mut self) + { + let mut phase_data = Vec::new(); + + // Collect all phases from all ingestions + for analysis in &self.active_ingestions + { + for phase in &analysis.phases + { + let start_hour = phase.duration_range.start.hour() as f64 + + phase.duration_range.start.minute() as f64 / 60.0; + let end_hour = phase.duration_range.end.hour() as f64 + + phase.duration_range.end.minute() as f64 / 60.0; + + // Store phase data with substance name for better visualization + phase_data.push(( + start_hour..end_hour, + phase.class, + analysis + .substance + .as_ref() + .map(|s| s.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()), + )); + } + } + + // Generate datasets for each phase type + self.effect_datasets = vec![ + (PhaseClassification::Onset, 0.3), + (PhaseClassification::Comeup, 0.7), + (PhaseClassification::Peak, 1.0), + (PhaseClassification::Comedown, 0.4), + (PhaseClassification::Afterglow, 0.2), + ] + .into_iter() + .map(|(phase_type, base_intensity)| { + let phase_points: Vec<(f64, f64)> = phase_data + .iter() + .filter(|(_, phase, _)| *phase == phase_type) + .flat_map(|(range, _, _substance)| { + self.generate_phase_points(range, base_intensity) + .into_iter() + .map(|(hour, intensity)| (hour, intensity)) + .collect::>() + }) + .collect(); + + (phase_type, phase_points) + }) + .collect(); + + // Calculate combined effects + let mut combined = vec![0.0; 24 * 12]; // Higher resolution for smoother curves + for (range, phase, _) in &phase_data + { + let intensity = match phase + { + | PhaseClassification::Peak => 1.0, + | PhaseClassification::Comeup => 0.7, + | PhaseClassification::Comedown => 0.4, + | PhaseClassification::Afterglow => 0.2, + | PhaseClassification::Onset => 0.3, + | _ => 0.0, + }; + + let start_idx = (range.start * 12.0) as usize; + let end_idx = (range.end * 12.0) as usize; + for idx in start_idx..=end_idx.min(combined.len() - 1) + { + let progress = (idx as f64 - start_idx as f64) / (end_idx - start_idx) as f64; + let effect = if progress < 0.2 + { + intensity * (progress / 0.2) + } + else if progress > 0.8 + { + intensity * ((1.0 - progress) / 0.2) + } + else + { + intensity + }; + combined[idx] += effect; + } + } + + // Convert combined effects to data points + self.combined_effects = combined + .iter() + .enumerate() + .map(|(i, &intensity)| { + let hour = i as f64 / 12.0; + (hour, intensity) + }) + .collect(); + + // Update current time marker + let current_hour = Local::now().hour() as f64 + Local::now().minute() as f64 / 60.0; + let current_idx = (current_hour * 12.0) as usize; + self.time_marker = vec![ + (current_hour, 0.0), + ( + current_hour, + combined.get(current_idx).copied().unwrap_or(0.0), + ), + ]; + } +} + +impl Renderable for DashboardCharts +{ + fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> + { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + // Render intensity timeline + let intensity_block = Block::default() + .title("24h Intensity Timeline") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let intensity_area = intensity_block.inner(chunks[0]); + frame.render_widget(intensity_block, chunks[0]); + + let intensity_data = self.calculate_hourly_intensity(); + let sparkline = Sparkline::default() + .block(Block::default()) + .style(Style::default().fg(Theme::GREEN)) + .data(&intensity_data); + + frame.render_widget(sparkline, intensity_area); + + // Render statistics + let stats_block = Block::default() + .title("Statistics") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)); + + let stats_area = stats_block.inner(chunks[1]); + frame.render_widget(stats_block, chunks[1]); + + let active_count = self.active_ingestions.len(); + let stats_text = vec![Line::from(vec![ + Span::raw("Active Ingestions: "), + Span::styled(active_count.to_string(), Style::default().fg(Theme::GREEN)), + ])]; + + frame.render_widget( + Paragraph::new(stats_text).alignment(Alignment::Left), + stats_area, + ); + + Ok(()) + } +} diff --git a/src/tui/widgets/journal_summary.rs b/src/tui/widgets/journal_summary.rs new file mode 100644 index 00000000..8e77ef8d --- /dev/null +++ b/src/tui/widgets/journal_summary.rs @@ -0,0 +1,150 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::ingestion::model::Ingestion; +use crate::tui::theme::Theme; +use chrono::{DateTime, Local}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; + +pub struct JournalSummary { + ingestions: Vec<(Ingestion, Option)>, +} + +impl JournalSummary { + + pub fn render(&self, frame: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(6), // Today's summary + Constraint::Min(10), // Timeline chart + ]) + .split(area); + + // Render title + let title = Paragraph::new("Journal Summary") + .style(Style::default().fg(Theme::MAUVE).add_modifier(Modifier::BOLD)) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(title, chunks[0]); + + // Render today's summary + self.render_summary(frame, chunks[1]); + + // Render timeline chart + self.render_timeline(frame, chunks[2]); + } + + fn render_summary(&self, frame: &mut Frame, area: Rect) { + let today = Local::now().date_naive(); + let today_ingestions: Vec<_> = self.ingestions + .iter() + .filter(|(i, _)| i.ingestion_date.date_naive() == today) + .collect(); + + let active_count = today_ingestions + .iter() + .filter(|(_, a)| { + a.as_ref().is_some_and(|analysis| { + let now = Local::now(); + now >= analysis.ingestion_start && now <= analysis.ingestion_end + }) + }) + .count(); + + let summary_text = vec![ + Line::from(vec![ + Span::styled("Today's Ingestions: ", Style::default().fg(Theme::TEXT)), + Span::styled( + today_ingestions.len().to_string(), + Style::default().fg(Theme::GREEN), + ), + ]), + Line::from(vec![ + Span::styled("Currently Active: ", Style::default().fg(Theme::TEXT)), + Span::styled( + active_count.to_string(), + Style::default().fg(Theme::YELLOW), + ), + ]), + ]; + + let summary = Paragraph::new(summary_text) + .block(Block::default().borders(Borders::ALL)) + .alignment(ratatui::layout::Alignment::Left); + + frame.render_widget(summary, area); + } + + fn render_timeline(&self, frame: &mut Frame, area: Rect) { + let today = Local::now().date_naive(); + let today_ingestions: Vec<_> = self.ingestions + .iter() + .filter(|(i, _)| i.ingestion_date.date_naive() == today) + .collect(); + + let timeline_block = Block::default() + .borders(Borders::ALL) + .title("Today's Timeline"); + + let inner_area = timeline_block.inner(area); + frame.render_widget(timeline_block, area); + + if today_ingestions.is_empty() { + let empty_msg = Paragraph::new("No ingestions recorded today") + .style(Style::default().fg(Theme::OVERLAY0)) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(empty_msg, inner_area); + return; + } + + let header = Row::new(vec!["Time", "Substance", "Status"]) + .style(Style::default().fg(Theme::TEXT)); + + let rows: Vec = today_ingestions + .iter() + .map(|(ingestion, analysis)| { + let time = ingestion.ingestion_date.format("%H:%M").to_string(); + let status = analysis.as_ref().map_or("Unknown", |a| { + let now = Local::now(); + if now < a.ingestion_start { + "Scheduled" + } else if now > a.ingestion_end { + "Completed" + } else { + "Active" + } + }); + + let status_style = match status { + "Active" => Style::default().fg(Theme::GREEN), + "Completed" => Style::default().fg(Theme::OVERLAY0), + "Scheduled" => Style::default().fg(Theme::BLUE), + _ => Style::default().fg(Theme::TEXT), + }; + + Row::new(vec![ + Cell::from(time), + Cell::from(ingestion.substance.clone()), + Cell::from(status).style(status_style), + ]) + }) + .collect(); + + let widths = &[ + Constraint::Length(8), + Constraint::Percentage(60), + Constraint::Percentage(20), + ]; + + let table = Table::new(rows, widths) + .header(header) + .column_spacing(1); + + frame.render_widget(table, inner_area); + } +} \ No newline at end of file diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 9bd8cffe..1076f507 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -3,8 +3,17 @@ use miette::Result; use ratatui::prelude::*; use super::core::Renderable; - +pub mod active_ingestions; +pub mod dashboard_charts; pub mod dosage; +pub mod journal_summary; +pub mod timeline_sidebar; + +pub use active_ingestions::ActiveIngestionPanel; +pub use dashboard_charts::DashboardCharts; +pub use dosage::dosage_dots; +pub use journal_summary::JournalSummary; +pub use timeline_sidebar::TimelineSidebar; pub trait EventHandler { @@ -45,4 +54,4 @@ pub trait Navigable fn selected(&self) -> Option; } -pub trait Component: Renderable {} +pub trait Component: Renderable {} \ No newline at end of file diff --git a/src/tui/widgets/timeline_sidebar.rs b/src/tui/widgets/timeline_sidebar.rs new file mode 100644 index 00000000..5b1eb89f --- /dev/null +++ b/src/tui/widgets/timeline_sidebar.rs @@ -0,0 +1,201 @@ +use crate::analyzer::model::IngestionAnalysis; +use crate::ingestion::model::Ingestion; +use crate::tui::core::Renderable; +use crate::tui::theme::Theme; +use chrono::{DateTime, Duration, Local, Timelike}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph}; +use std::collections::{BTreeMap, HashSet}; + +pub struct TimelineSidebar { + ingestions: BTreeMap, Vec<(Ingestion, Option)>>, +} + +impl TimelineSidebar { + pub fn new() -> Self { + Self { + ingestions: BTreeMap::new(), + } + } + + pub fn update(&mut self, ingestions: Vec<(Ingestion, Option)>) { + self.ingestions.clear(); + + // Group ingestions by hour within the 24-hour window + let now = Local::now(); + let window_start = now - Duration::hours(12); + let window_end = now + Duration::hours(12); + + for (ingestion, analysis) in ingestions { + let date = ingestion.ingestion_date; + if date >= window_start && date <= window_end { + // Calculate all hours this ingestion spans + let start_hour = date.with_minute(0).unwrap().with_second(0).unwrap().with_nanosecond(0).unwrap(); + let end_hour = if let Some(analysis) = &analysis { + analysis.ingestion_end.with_minute(0).unwrap().with_second(0).unwrap().with_nanosecond(0).unwrap() + } else { + start_hour + Duration::hours(1) + }; + + let mut current = start_hour; + while current <= end_hour { + self.ingestions + .entry(current) + .or_insert_with(Vec::new) + .push((ingestion.clone(), analysis.clone())); + current = current + Duration::hours(1); + } + } + } + } + + fn render_timeline_entry( + &self, + time: DateTime, + ingestions: &[(Ingestion, Option)], + ) -> Option>> { + // If no ingestions and not the current hour, return None to collapse + let now = Local::now(); + let is_current = time.hour() == now.hour() && time.date_naive() == now.date_naive(); + + if ingestions.is_empty() && !is_current { + return None; + } + + let mut items = Vec::new(); + let is_past = time < now; + + // Hour marker with distinct symbols + let (hour_marker, marker_style) = match (is_current, is_past) { + (true, _) => ("➤", Style::default().fg(Theme::YELLOW).add_modifier(Modifier::BOLD)), + (false, true) => ("•", Style::default().fg(Theme::TEXT)), + (false, false) => ("○", Style::default().fg(Theme::MAUVE)), + }; + + // Format the hour label + let hour_label = format!("{:02}:00", time.hour()); + + // Add the hour marker and label + items.push(ListItem::new(Line::from(vec![ + Span::styled(hour_marker, marker_style), + Span::raw(" "), + Span::styled(hour_label, Style::default().add_modifier(Modifier::BOLD)), + ]))); + + // Deduplicate substances to handle multi-block ingestions + let unique_substances: HashSet<_> = ingestions + .iter() + .map(|(ingestion, _)| ingestion.substance.clone()) + .collect(); + + // Add ingestions under the hour + if !ingestions.is_empty() { + for substance in unique_substances { + // Find all ingestions for this substance + let substance_ingestions: Vec<_> = ingestions + .iter() + .filter(|(ingestion, _)| ingestion.substance == substance) + .collect(); + + // Aggregate dosages and phases + let total_dosage: f64 = substance_ingestions + .iter() + .map(|(ingestion, _)| ingestion.dosage.as_base_units()) + .sum(); + + let phases: Vec<_> = substance_ingestions + .iter() + .filter_map(|(_, analysis)| + analysis.as_ref().and_then(|a| a.current_phase) + ) + .collect(); + + // Determine style based on time + let ingestion_style = if is_past { + Style::default().fg(Theme::GREEN) + } else { + Style::default().fg(Theme::BLUE) + }; + + // Combine unique phases + let phase_text = if !phases.is_empty() { + format!(" ({})", phases.iter().map(|p| p.to_string()).collect::>().into_iter().collect::>().join(", ")) + } else { + String::new() + }; + + items.push(ListItem::new(Line::from(vec![ + Span::raw(" "), // Indentation for sub-items + Span::styled("• ", Style::default().fg(Theme::TEXT)), + Span::styled(substance, ingestion_style), + Span::raw(" - "), + Span::styled(format!("{:.1}mg", total_dosage), Style::default().fg(Theme::TEXT)), + Span::styled(phase_text, Style::default().fg(Theme::MAUVE)), + ]))); + } + } else { + // Indicate no ingestions for this hour + items.push(ListItem::new(Line::from(vec![ + Span::raw(" "), + Span::styled("No ingestions", Style::default().fg(Theme::OVERLAY0)), + ]))); + } + + Some(items) + } +} + +impl Renderable for TimelineSidebar { + fn render(&self, area: Rect, frame: &mut Frame) -> miette::Result<()> { + let block = Block::default() + .title("Timeline") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(Theme::SURFACE0)) + .padding(Padding::new(1, 1, 1, 1)); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + if self.ingestions.is_empty() { + frame.render_widget( + Paragraph::new("No ingestions in the timeline window.") + .style(Style::default().fg(Theme::TEXT)) + .alignment(Alignment::Center), + inner_area, + ); + return Ok(()); + } + + let mut items = Vec::new(); + + let now = Local::now(); + let window_start = now - Duration::hours(12); + let window_end = now + Duration::hours(12); + let mut current = window_start; + + while current <= window_end { + if let Some(ingestions) = self.ingestions.get(¤t) { + if let Some(hour_items) = self.render_timeline_entry(current, ingestions) { + items.extend(hour_items); + } + } else { + // Show current hour even if no ingestions + if let Some(hour_items) = self.render_timeline_entry(current, &[]) { + items.extend(hour_items); + } + } + current = current + Duration::hours(1); + } + + frame.render_widget( + List::new(items) + .block(Block::default()) + .style(Style::default().fg(Theme::TEXT)) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)), + inner_area, + ); + + Ok(()) + } +} \ No newline at end of file