From d1667955f78c14646135511228df306d4ff01044 Mon Sep 17 00:00:00 2001 From: keinsell Date: Mon, 27 Jan 2025 09:43:45 +0100 Subject: [PATCH] [DIRTY] add dashboard to terminal user interface --- .cargo/config.toml | 7 + TASK.md | 231 ++++------ docs/concepts/wordcel.md | 406 ++++++++++++++++++ docs/product-concept-map.md | 27 ++ src/ingestion/query.rs | 4 +- src/journal/tests/mod.rs | 1 + .../route_of_administration/README.md | 9 + src/tui/README.md | 65 +++ src/tui/app.rs | 228 ++++++---- src/tui/events.rs | 3 +- src/tui/layout/footer.rs | 16 +- src/tui/layout/header.rs | 10 +- src/tui/views/home.md | 68 +++ src/tui/views/home.rs | 179 ++++++++ src/tui/views/mod.rs | 6 +- src/tui/views/welcome.rs | 68 ++- src/tui/widgets/active_ingestions.rs | 85 ++++ src/tui/widgets/dashboard_charts.rs | 341 +++++++++++++++ src/tui/widgets/journal_summary.rs | 150 +++++++ src/tui/widgets/mod.rs | 13 +- src/tui/widgets/timeline_sidebar.rs | 211 +++++++++ 21 files changed, 1835 insertions(+), 293 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 docs/concepts/wordcel.md create mode 100644 docs/product-concept-map.md create mode 100644 src/journal/tests/mod.rs create mode 100644 src/substance/route_of_administration/README.md create mode 100644 src/tui/README.md create mode 100644 src/tui/views/home.md create mode 100644 src/tui/views/home.rs create mode 100644 src/tui/widgets/active_ingestions.rs create mode 100644 src/tui/widgets/dashboard_charts.rs create mode 100644 src/tui/widgets/journal_summary.rs create mode 100644 src/tui/widgets/timeline_sidebar.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..73b37042 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +[profile.test] +opt-level = 1 +debug = true +debug-assertions = true + +[term] +verbose = true 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/docs/concepts/wordcel.md b/docs/concepts/wordcel.md new file mode 100644 index 00000000..b52d633a --- /dev/null +++ b/docs/concepts/wordcel.md @@ -0,0 +1,406 @@ +Here’s a list of potential names for the sidebar that shows today’s ingestions, categorized by tone and purpose: + +### **Descriptive and Functional Names** +1. **Today’s Timeline** +2. **Ingestion Timeline** +3. **Daily Log** +4. **Today’s Intake** +5. **Hourly Overview** +6. **Daily Summary** +7. **Ingestion History** +8. **Today’s Schedule** +9. **Daily Progress** +10. **Intake Tracker** + +### **Minimalist and Clean Names** +1. **Timeline** +2. **Log** +3. **Today** +4. **Overview** +5. **Summary** +6. **Track** +7. **History** +8. **Progress** +9. **Flow** +10. **Chronology** + +### **Engaging and Creative Names** +1. **The Timeline** +2. **Daily Flow** +3. **Hourly Pulse** +4. **Ingestion Flow** +5. **Today’s Journey** +6. **The Logbook** +7. **Daily Pulse** +8. **Time Tracker** +9. **The Chronicle** +10. **Hourly Log** + +### **Scientific and Technical Names** +1. **Ingestion Chronology** +2. **Dosage Timeline** +3. **Substance Log** +4. **Intake Chronology** +5. **Hourly Intake** +6. **Dosage Tracker** +7. **Substance Timeline** +8. **Pharmacokinetic Log** +9. **Daily Dosage** +10. **Intake Chronograph** + +### **User-Friendly and Approachable Names** +1. **My Day** +2. **Your Timeline** +3. **Daily Recap** +4. **Today’s Plan** +5. **My Intake** +6. **Daily Tracker** +7. **Your Log** +8. **Today’s Tracker** +9. **My Progress** +10. **Daily Diary** + +### **Suggestions Based on Use Case** +- If the sidebar focuses on **planning future ingestions**, consider names like: + - **Upcoming Intake** + - **Future Schedule** + - **Planned Timeline** + - **Next Steps** + +- If the sidebar emphasizes **past ingestions**, consider names like: + - **Recent Intake** + - **Past Log** + - **History Tracker** + - **Completed Timeline** + +- If the sidebar is **interactive**, consider names like: + - **Live Timeline** + - **Dynamic Log** + - **Interactive Tracker** + - **Real-Time Overview** + +### **Top Recommendations** +1. **Today’s Timeline** (clear and descriptive) +2. **Daily Log** (simple and functional) +3. **Ingestion Flow** (engaging and dynamic) +4. **Hourly Overview** (specific and informative) +5. **The Chronicle** (creative and memorable) + +Choose a name that aligns with the tone of your application and the user experience you want to create! + +Here’s an expanded list of names specifically tailored for a drug dosage tracking app, categorized by tone and focus: + +--- + +### **Descriptive and Functional Names** +1. **Dosage Tracker** +2. **Medication Log** +3. **Dose Diary** +4. **Intake Tracker** +5. **Medication Timeline** +6. **Dosage Schedule** +7. **Daily Dosage** +8. **Medication Planner** +9. **Dose Reminder** +10. **Medication Journal** +11. **Dosage History** +12. **Intake Journal** +13. **Medication Tracker** +14. **Dose Monitor** +15. **Medication Overview** + +--- + +### **Minimalist and Clean Names** +1. **Dose** +2. **Log** +3. **Track** +4. **Med** +5. **Plan** +6. **DoseIt** +7. **MedLog** +8. **DoseUp** +9. **MedTrack** +10. **DoseMe** + +--- + +### **Engaging and Creative Names** +1. **DoseWise** +2. **MedFlow** +3. **DoseSync** +4. **MedPulse** +5. **DoseMate** +6. **MedMinder** +7. **DoseNest** +8. **MedVibe** +9. **DoseSphere** +10. **MedEcho** + +--- + +### **Scientific and Technical Names** +1. **PharmacoLog** +2. **Dosage Chronology** +3. **Medication Analytics** +4. **DoseMetrics** +5. **PharmaTrack** +6. **Medication Matrix** +7. **DoseGraph** +8. **PharmaLog** +9. **Medication Spectrum** +10. **DoseLab** + +--- + +### **User-Friendly and Approachable Names** +1. **My Meds** +2. **Your Dose** +3. **Med Buddy** +4. **DosePal** +5. **Med Helper** +6. **DoseFriend** +7. **My Dose Diary** +8. **Med Companion** +9. **Dose Keeper** +10. **Med Ally** + +--- + +### **Names with a Focus on Health and Wellness** +1. **HealthTrack** +2. **Wellness Dose** +3. **CareLog** +4. **HealthSync** +5. **Wellness Tracker** +6. **CareMate** +7. **HealthPulse** +8. **Wellness Planner** +9. **CareSphere** +10. **HealthFlow** + +--- + +### **Names with a Focus on Precision and Control** +1. **DosePerfect** +2. **MedControl** +3. **Precision Dose** +4. **DoseMaster** +5. **MedPrecision** +6. **DosePro** +7. **MedSync** +8. **DoseControl** +9. **MedMaster** +10. **DosePrecision** + +--- + +### **Names with a Focus on Time and Scheduling** +1. **DoseTime** +2. **MedSchedule** +3. **TimeDose** +4. **DoseClock** +5. **MedTimer** +6. **DoseSync** +7. **MedChron** +8. **DosePlan** +9. **MedTime** +10. **DoseSchedule** + +--- + +### **Names with a Focus on Reminders and Alerts** +1. **DoseAlert** +2. **MedRemind** +3. **DoseNotify** +4. **MedAlarm** +5. **DoseBell** +6. **MedPrompt** +7. **DoseSignal** +8. **MedBuzz** +9. **DosePing** +10. **MedAlert** + +--- + +### **Top Recommendations** +1. **DoseWise** (engaging and smart) +2. **MedTrack** (simple and functional) +3. **DoseSync** (modern and dynamic) +4. **My Meds** (user-friendly and approachable) +5. **PharmacoLog** (scientific and professional) + +--- + +### **Bonus: Names for Specific Features** +- For a **timeline view**: **DoseFlow**, **MedChronology**, **TimeTrack** +- For a **reminder system**: **DoseBell**, **MedAlarm**, **NotifyDose** +- For a **data analytics feature**: **DoseMetrics**, **MedAnalytics**, **PharmaGraph** + +Choose a name that resonates with your app’s core functionality and target audience! + +Here’s a comprehensive list of potential feature names for a drug dosage tracking app, categorized by functionality: + +--- + +### **Core Tracking Features** +1. **Dosage Tracker** +2. **Medication Log** +3. **Intake History** +4. **Daily Schedule** +5. **Dose Reminder** +6. **Medication Timeline** +7. **Substance Tracker** +8. **Dose Journal** +9. **Intake Planner** +10. **Medication Diary** + +--- + +### **Reminder and Notification Features** +1. **Dose Alerts** +2. **Medication Reminders** +3. **Schedule Notifications** +4. **Missed Dose Alert** +5. **Upcoming Dose** +6. **Custom Reminders** +7. **Repeat Alarms** +8. **Snooze Notifications** +9. **Dose Bell** +10. **Medication Buzz** + +--- + +### **Analytics and Insights Features** +1. **Dose Analytics** +2. **Intake Trends** +3. **Medication Insights** +4. **Dosage Reports** +5. **Health Metrics** +6. **Progress Tracker** +7. **Dose History** +8. **Substance Analysis** +9. **Daily Summary** +10. **Weekly Recap** + +--- + +### **Planning and Scheduling Features** +1. **Dose Planner** +2. **Medication Calendar** +3. **Future Schedule** +4. **Custom Plans** +5. **Dose Sync** +6. **Medication Timeline** +7. **Intake Schedule** +8. **Dose Organizer** +9. **Medication Agenda** +10. **Daily Planner** + +--- + +### **Health and Safety Features** +1. **Dose Safety Check** +2. **Interaction Alerts** +3. **Overdose Warning** +4. **Side Effect Tracker** +5. **Health Monitor** +6. **Dose Limits** +7. **Medication Safety** +8. **Health Alerts** +9. **Dose Advisor** +10. **Safety Insights** + +--- + +### **Customization and Personalization Features** +1. **Custom Profiles** +2. **Personalized Plans** +3. **Dose Preferences** +4. **Medication Groups** +5. **Custom Reminders** +6. **Themed Layouts** +7. **User Settings** +8. **Personalized Alerts** +9. **Custom Categories** +10. **Tailored Reports** + +--- + +### **Integration and Sharing Features** +1. **Health Sync** +2. **Data Export** +3. **Share Reports** +4. **Doctor Connect** +5. **Family Sharing** +6. **Cloud Backup** +7. **Integration Hub** +8. **Data Import** +9. **Caregiver Access** +10. **Health App Sync** + +--- + +### **Educational and Informational Features** +1. **Medication Guide** +2. **Dose Tips** +3. **Health Articles** +4. **Substance Info** +5. **FAQ Hub** +6. **Dosage Calculator** +7. **Interaction Checker** +8. **Health Resources** +9. **Educational Videos** +10. **Medication Glossary** + +--- + +### **Gamification and Motivation Features** +1. **Dose Streaks** +2. **Achievements** +3. **Progress Badges** +4. **Daily Goals** +5. **Rewards System** +6. **Motivation Quotes** +7. **Challenge Mode** +8. **Milestone Tracker** +9. **Dose Leaderboard** +10. **Health Challenges** + +--- + +### **Advanced Features** +1. **AI Dose Advisor** +2. **Predictive Analytics** +3. **Smart Reminders** +4. **Voice Commands** +5. **AR Dose Guide** +6. **Wearable Integration** +7. **AI Insights** +8. **Smart Scheduling** +9. **Dose Optimization** +10. **AI Safety Check** + +--- + +### **Top Feature Name Recommendations** +1. **Dose Tracker** (core functionality) +2. **Medication Reminders** (essential for adherence) +3. **Intake Insights** (analytics and trends) +4. **Dose Planner** (scheduling and planning) +5. **Safety Check** (health and safety focus) +6. **Custom Profiles** (personalization) +7. **Health Sync** (integration with other apps) +8. **Medication Guide** (educational resource) +9. **Dose Streaks** (gamification for motivation) +10. **AI Dose Advisor** (advanced feature) + +--- + +### **Feature Naming Tips** +- Use **clear and descriptive names** for core features (e.g., "Dose Tracker"). +- Use **engaging names** for motivational features (e.g., "Dose Streaks"). +- Use **technical names** for advanced features (e.g., "AI Dose Advisor"). +- Keep names **short and memorable** for ease of use. + +Let me know if you’d like help refining these further! diff --git a/docs/product-concept-map.md b/docs/product-concept-map.md new file mode 100644 index 00000000..ad151bd6 --- /dev/null +++ b/docs/product-concept-map.md @@ -0,0 +1,27 @@ +# Neuronek + +## Interface + +### Home + +#### Ingestions + +#### Journal + +#### Substance + +#### s... + +## Domain + +### Ingestion + +#### Ingestion Analyzer + +### Substance + +#### Route of Administration + +##### Phase + +##### Dosage \ No newline at end of file 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/substance/route_of_administration/README.md b/src/substance/route_of_administration/README.md new file mode 100644 index 00000000..91dfab5d --- /dev/null +++ b/src/substance/route_of_administration/README.md @@ -0,0 +1,9 @@ +# Route of Administration + +## Phases + +## Dosages + +### Dosage Classification + +#### \ No newline at end of file diff --git a/src/tui/README.md b/src/tui/README.md new file mode 100644 index 00000000..3d6f6acd --- /dev/null +++ b/src/tui/README.md @@ -0,0 +1,65 @@ +# Terminal User Interface (TUI) + +Terminal-based user interface (TUI) for managing and visualizing substance ingestions. It features a dashboard with charts, a timeline sidebar, and a statistics bar, along with views for creating, listing, and viewing ingestions. The TUI uses `ratatui` for rendering and provides a structured approach to handling events, state, and focus. + +## 1. Overall Structure + +The TUI is organized into several modules, each with a specific responsibility: + +* **`src/tui/app.rs` (startLine: 1 endLine: 670):** This is the core of the application, managing the overall state, event handling, and rendering loop. It initializes the TUI, handles user input, and updates the UI based on application logic. +* **`src/tui/core.rs` (startLine: 1 endLine: 78):** Provides core abstractions for the TUI, including the `Renderable` trait, which defines how UI components are rendered. It also defines `DefaultTerminal` type. +* **`src/tui/events.rs` (startLine: 1 endLine: 72):** Defines the events and messages used for communication within the application. It includes `AppEvent` (user input, ticks, resize) and `AppMessage` (application actions like navigation, data loading). It also defines `Screen` enum which represents different views in the application. +* **`src/tui/layout/` (startLine: 1 endLine: 3):** Contains modules for layout components like the header, footer, and help screen. + * **`src/tui/layout/header.rs` (startLine: 1 endLine: 121):** Implements the application header, displaying the current screen and providing navigation shortcuts. + * **`src/tui/layout/footer.rs` (startLine: 1 endLine: 294):** Implements the application footer, displaying status messages and providing context-sensitive actions. + * **`src/tui/layout/help.rs` (startLine: 1 endLine: 319):** Implements the help screen, displaying application information and keybindings. +* **`src/tui/theme.rs` (startLine: 1 endLine: 78):** Defines the color palette and styles used throughout the TUI, based on the Catppuccin Mocha theme. +* **`src/tui/views/` (startLine: 1 endLine: 7):** Contains modules for different views within the application. + * **`src/tui/views/home.rs` (startLine: 1 endLine: 34):** Implements the main dashboard view, displaying active ingestions and their analysis. + * **`src/tui/views/ingestion/` (startLine: 1 endLine: 3):** Contains modules for managing ingestions. + * **`src/tui/views/ingestion/create_ingestion.rs` (startLine: 1 endLine: 670):** Implements the form for creating new ingestions. + * **`src/tui/views/ingestion/get_ingestion.rs` (startLine: 1 endLine: 105):** Implements the view for displaying details of a single ingestion. + * **`src/tui/views/ingestion/list_ingestion.rs` (startLine: 1 endLine: 360):** Implements the view for listing all ingestions. + * **`src/tui/views/loading.rs` (startLine: 1 endLine: 111):** Implements the loading screen, displayed while data is being fetched. + * **`src/tui/views/welcome.rs` (startLine: 1 endLine: 88):** Implements the welcome screen, displayed when the application starts. +* **`src/tui/widgets/` (startLine: 1 endLine: 51):** Contains reusable UI components. + * **`src/tui/widgets/active_ingestions.rs`:** Implements a panel for displaying active ingestions. + * **`src/tui/widgets/dashboard_charts.rs` (startLine: 1 endLine: 280):** Implements the dashboard charts, visualizing intensity and other data. + * **`src/tui/widgets/timeline_sidebar.rs` (startLine: 1 endLine: 351):** Implements the timeline sidebar, displaying a chronological view of ingestions. + * **`src/tui/widgets/dosage.rs` (startLine: 1 endLine: 11):** Implements helper functions for displaying dosage information. + * **`src/tui/widgets/journal_summary.rs` (startLine: 1 endLine: 120):** Implements a summary of journal entries. + +## 2. Architecture + +The TUI follows a reactive architecture, where the UI is updated in response to events. + +* **Event Handling:** The `src/tui/app.rs` (startLine: 1 endLine: 670) manages the main event loop. It listens for user input (keyboard and mouse events) and dispatches them to the appropriate components. The `EventHandler` trait (src/tui/widgets/mod.rs startLine: 17 endLine: 21) is used by components to handle events and produce messages. +* **State Management:** The application state is primarily managed within the `src/tui/app.rs` (startLine: 1 endLine: 670) struct. Each view and widget also maintains its own internal state. The `Stateful` trait (src/tui/widgets/mod.rs startLine: 23 endLine: 28) is used by components to update their state based on messages. +* **Rendering:** The `Renderable` trait (src/tui/core.rs startLine: 10 endLine: 78) defines how UI components are rendered. Each component implements this trait, providing a `render` function that draws the component within a specified area of the terminal. The `ratatui` library is used for rendering. +* **Data Flow:** Data is fetched and processed in the `src/tui/app.rs` (startLine: 1 endLine: 670) and passed to the views and widgets. For example, active ingestions are fetched and passed to the `Home` view, which then passes them to the `DashboardCharts` and `TimelineSidebar` widgets. +* **Navigation:** The `AppMessage::NavigateToPage` message (src/tui/events.rs startLine: 38 endLine: 47) is used to switch between different screens. The `Header` component (src/tui/layout/header.rs startLine: 1 endLine: 121) provides keyboard shortcuts for navigation. + +## 3. User Experience + +The TUI aims to provide a clear and efficient user experience: + +* **Welcome Screen:** The application starts with a welcome screen (src/tui/views/welcome.rs startLine: 1 endLine: 88) that displays the application logo and provides navigation instructions. +* **Home Dashboard:** The main dashboard (src/tui/views/home.rs startLine: 1 endLine: 34) provides an overview of active ingestions, their intensity, and a timeline of events. It uses charts and a sidebar to present information visually. +* **Ingestion Management:** The application allows users to create, list, and view ingestions. The `CreateIngestionState` (src/tui/views/ingestion/create_ingestion.rs startLine: 1 endLine: 670) provides a form for creating new ingestions, while the `IngestionListState` (src/tui/views/ingestion/list_ingestion.rs startLine: 1 endLine: 360) displays a list of existing ingestions. The `IngestionViewState` (src/tui/views/ingestion/get_ingestion.rs startLine: 1 endLine: 105) provides a detailed view of a single ingestion. +* **Navigation:** The user can navigate between different screens using keyboard shortcuts (1, 2, 0, ?). The header (src/tui/layout/header.rs startLine: 1 endLine: 121) displays the current screen and provides navigation hints. +* **Status Messages:** The footer (src/tui/layout/footer.rs startLine: 1 endLine: 294) displays status messages and context-sensitive actions. +* **Help Screen:** The help screen (src/tui/layout/help.rs startLine: 1 endLine: 319) provides information about the application and its keybindings. +* **Loading Screen:** A loading screen (src/tui/views/loading.rs startLine: 1 endLine: 111) is displayed while data is being fetched, providing visual feedback to the user. +* **Theme:** The TUI uses a consistent color scheme (src/tui/theme.rs startLine: 1 endLine: 78) based on the Catppuccin Mocha theme, providing a visually appealing and consistent experience. + +In summary, the TUI is a well-structured application that provides a focused and efficient user experience for managing and visualizing substance ingestions. It uses a reactive architecture, a clear separation of concerns, and a consistent visual theme to achieve its goals. + +## 4. Future Improvements + +- Management of screens +- Management of data +- Routing of events +- background tasks +- components lifecycle +- keybind managemenet +- theme management \ No newline at end of file diff --git a/src/tui/app.rs b/src/tui/app.rs index 0a182633..b75abb9e 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,44 @@ 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") + .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(); + + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(70), + Constraint::Percentage(30), + ]) + .spacing(1) + .split(home_area); + + // Update dashboard charts with current data + self.dashboard_charts + .set_active_ingestions(self.active_ingestions.iter() + .filter_map(|(_, analysis)| analysis.clone()) + .collect()); + + // Render timeline with full ingestion data + let mut timeline = TimelineSidebar::new(); + timeline.update(self.active_ingestions.clone()); + let _ = timeline.render(content_chunks[1], frame); } | Screen::CreateIngestion => { @@ -336,6 +384,54 @@ 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(); + let window_start = chrono::Local::now() - chrono::Duration::hours(12); + let window_end = chrono::Local::now() + chrono::Duration::hours(12); + + 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 + { + if analysis.ingestion_end >= window_start && analysis.ingestion_start <= window_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 +479,9 @@ impl Application self.background_tasks .retain(|_, (_, completed)| !*completed); + // Update active ingestions + self.update_active_ingestions().await?; + Ok(()) } @@ -396,6 +495,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 +513,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 +610,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 +627,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 +650,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 +686,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 +705,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 +714,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.md b/src/tui/views/home.md new file mode 100644 index 00000000..a1c54c33 --- /dev/null +++ b/src/tui/views/home.md @@ -0,0 +1,68 @@ +# Home Dashboard + +The Home Dashboard provides a comprehensive overview of active substance ingestions and their current states. The interface consists of three core sections: + +## Layout Structure + +### 1. Statistics Bar +A horizontal bar at the top showing key metrics: +- Active ingestions count +- Number of unique substances +- Total combined dosage in mg +- Time until next phase change +- Number of substances in comedown phase + +### 2. Main Content Area (70%) +Contains the dashboard charts that visualize: +- Intensity patterns +- Phase distributions +- Timeline correlations +- Key event markers + +### 3. Timeline Sidebar (30%) +A chronological display of ingestions that shows: +- Substance names and dosages +- Current phases and timestamps +- Progress indicators +- Status markers + +## Implementation Details + +### Data Flow +The view receives: +- Active ingestion records +- Associated analysis data for each ingestion +- Phase classification data + +### Rendering Pipeline +1. Layouts are computed using ratatui constraints +2. Statistics are calculated from active ingestions +3. Dashboard charts are rendered via DashboardCharts +4. Timeline is rendered via TimelineSidebar + +### Key Features +- Real-time updates of phase changes +- Automatic unit conversion for dosages +- Phase-aware status tracking +- Integrated error handling + +## Technical Architecture + +### Component Hierarchy +``` +Home +├─ Statistics Bar +├─ Dashboard Charts +│ ├─ Intensity Graph +│ └─ Status Panels +└─ Timeline Sidebar +``` + +### Data Dependencies +- `Ingestion` records +- `IngestionAnalysis` data +- Phase classification +- Theme configuration + +### Error Handling +All rendering operations return `miette::Result` to ensure proper error propagation and handling. diff --git a/src/tui/views/home.rs b/src/tui/views/home.rs new file mode 100644 index 00000000..fa729f11 --- /dev/null +++ b/src/tui/views/home.rs @@ -0,0 +1,179 @@ +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; + +#[doc = include_str!("./home.md")] +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..74502aab --- /dev/null +++ b/src/tui/widgets/timeline_sidebar.rs @@ -0,0 +1,211 @@ +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(); + + let now = Local::now(); + let window_start = (now - Duration::hours(12)).with_minute(0).unwrap(); + let window_end = (now + Duration::hours(12)).with_minute(0).unwrap(); + + for (ingestion, analysis) in ingestions { + if let Some(analysis) = analysis { + // Calculate first and last relevant hours + let mut current_hour = analysis.ingestion_start + .max(window_start) + .with_minute(0) + .unwrap(); + + let end_hour = analysis.ingestion_end + .min(window_end) + .with_minute(0) + .unwrap(); + + // Add to all hours between start and end + while current_hour <= end_hour { + self.ingestions + .entry(current_hour) + .or_default() + .push((ingestion.clone(), Some(analysis.clone()))); + + current_hour += 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| { + // Show phase if current hour is within phase duration + let hour_start = time; + let hour_end = time + Duration::hours(1); + a.phases.iter().find(|phase| + phase.duration_range.start < hour_end && + phase.duration_range.end > hour_start + ).map(|p| p.class) + }) + }) + .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