use std::time::Instant;
use thiserror::Error;
use tui_logger::TuiWidgetState;
use crate::models::{Filter, Priority, Project, SortOrder, Task, TaskStatus};
use crate::storage::{Database, StorageError, Tag};
use crate::ui::calendar::CalendarState;
use crate::ui::dialogs::Dialog;
use crate::ui::search::SearchResult;
const STATUS_MESSAGE_TIMEOUT_SECS: u64 = 3;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Storage error: {0}")]
Storage(#[from] StorageError),
}
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum View {
#[default]
Main,
TaskDetail,
Calendar,
Search,
Help,
DebugLogs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
#[default]
Normal,
Editing,
Search,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FocusPanel {
Sidebar,
#[default]
TaskList,
}
pub struct App {
pub db: Database,
pub tasks: Vec<Task>,
pub projects: Vec<Project>,
pub tags: Vec<Tag>,
pub current_view: View,
pub input_mode: InputMode,
pub focus: FocusPanel,
pub selected_task_index: Option<usize>,
pub selected_project_index: usize,
pub filter: Filter,
pub sort: SortOrder,
pub input_buffer: String,
pub input_cursor: usize,
pub log_state: TuiWidgetState,
pub should_quit: bool,
pub status_message: Option<String>,
status_message_set_at: Option<Instant>,
pub editing_task: Option<Task>,
pub dialog: Option<Dialog>,
pub search_results: Vec<SearchResult>,
pub selected_search_index: usize,
pub calendar_state: CalendarState,
}
impl App {
pub async fn new(db: Database) -> Result<Self> {
let mut app = Self {
db,
tasks: Vec::new(),
projects: Vec::new(),
tags: Vec::new(),
current_view: View::Main,
input_mode: InputMode::Normal,
focus: FocusPanel::TaskList,
selected_task_index: None,
selected_project_index: 0,
filter: Filter::Pending,
sort: SortOrder::DueDateAsc,
input_buffer: String::new(),
input_cursor: 0,
log_state: TuiWidgetState::default(),
should_quit: false,
status_message: None,
status_message_set_at: None,
editing_task: None,
dialog: None,
search_results: Vec::new(),
selected_search_index: 0,
calendar_state: CalendarState::new(),
};
app.load_data().await?;
Ok(app)
}
pub async fn load_data(&mut self) -> Result<()> {
self.tasks = self.db.get_all_tasks().await?;
self.projects = self.db.get_all_projects().await?;
self.tags = self.db.get_all_tags().await?;
let visible_count = self.visible_tasks().len();
if visible_count == 0 {
self.selected_task_index = None;
} else if let Some(idx) = self.selected_task_index {
if idx >= visible_count {
self.selected_task_index = Some(visible_count.saturating_sub(1));
}
} else {
self.selected_task_index = Some(0);
}
Ok(())
}
pub async fn refresh(&mut self) -> Result<()> {
self.load_data().await
}
pub fn visible_tasks(&self) -> Vec<&Task> {
let project_filter = if self.selected_project_index == 0 {
None } else {
self.projects
.get(self.selected_project_index - 1)
.map(|p| p.id.clone())
};
let mut tasks: Vec<&Task> = self
.tasks
.iter()
.filter(|t| {
if let Some(ref proj_id) = project_filter
&& t.project_id.as_ref() != Some(proj_id)
{
return false;
}
self.filter.matches(t)
})
.collect();
self.sort.apply(&mut tasks);
tasks
}
pub fn selected_task(&self) -> Option<&Task> {
let tasks = self.visible_tasks();
self.selected_task_index.and_then(|idx| tasks.get(idx).copied())
}
pub fn project_tasks(&self) -> Vec<&Task> {
if self.selected_project_index == 0 {
self.tasks.iter().collect()
} else if let Some(project) = self.projects.get(self.selected_project_index - 1) {
self.tasks
.iter()
.filter(|t| t.project_id.as_ref() == Some(&project.id))
.collect()
} else {
self.tasks.iter().collect()
}
}
pub fn selected_project_name(&self) -> &str {
if self.selected_project_index == 0 {
"All Tasks"
} else if let Some(project) = self.projects.get(self.selected_project_index - 1) {
&project.name
} else {
"All Tasks"
}
}
pub fn selected_project(&self) -> Option<&Project> {
if self.selected_project_index == 0 {
None
} else {
self.projects.get(self.selected_project_index - 1)
}
}
pub fn task_count_for_project(&self, project_id: &str) -> usize {
self.tasks
.iter()
.filter(|t| t.project_id.as_ref().map(|id| id == project_id).unwrap_or(false))
.filter(|t| t.status != TaskStatus::Archived)
.count()
}
pub fn total_task_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.status != TaskStatus::Archived)
.count()
}
pub fn overdue_count(&self) -> usize {
self.tasks.iter().filter(|t| t.is_overdue()).count()
}
pub fn due_today_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.is_due_today() && t.status != TaskStatus::Completed)
.count()
}
pub fn in_progress_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
.count()
}
pub fn completed_count(&self) -> usize {
self.tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed)
.count()
}
pub fn task_count_for_tag(&self, tag_name: &str) -> usize {
self.tasks
.iter()
.filter(|t| t.tags.contains(&tag_name.to_string()))
.filter(|t| t.status != TaskStatus::Archived)
.count()
}
pub fn select_previous_task(&mut self) {
let count = self.visible_tasks().len();
if count == 0 {
self.selected_task_index = None;
return;
}
self.selected_task_index = Some(match self.selected_task_index {
Some(0) => count - 1, Some(i) => i - 1,
None => 0,
});
}
pub fn select_next_task(&mut self) {
let count = self.visible_tasks().len();
if count == 0 {
self.selected_task_index = None;
return;
}
self.selected_task_index = Some(match self.selected_task_index {
Some(i) if i >= count - 1 => 0, Some(i) => i + 1,
None => 0,
});
}
pub fn select_previous_project(&mut self) {
let count = self.projects.len() + 1; if self.selected_project_index == 0 {
self.selected_project_index = count - 1;
} else {
self.selected_project_index -= 1;
}
self.update_task_selection();
}
pub fn select_next_project(&mut self) {
let count = self.projects.len() + 1; self.selected_project_index = (self.selected_project_index + 1) % count;
self.update_task_selection();
}
fn update_task_selection(&mut self) {
let count = self.visible_tasks().len();
self.selected_task_index = if count > 0 { Some(0) } else { None };
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
FocusPanel::Sidebar => FocusPanel::TaskList,
FocusPanel::TaskList => FocusPanel::Sidebar,
};
}
pub fn set_status(&mut self, message: impl Into<String>) {
self.status_message = Some(message.into());
self.status_message_set_at = Some(Instant::now());
}
pub fn clear_status(&mut self) {
self.status_message = None;
self.status_message_set_at = None;
}
pub fn on_tick(&mut self) {
if let Some(set_at) = self.status_message_set_at
&& set_at.elapsed().as_secs() >= STATUS_MESSAGE_TIMEOUT_SECS {
self.clear_status();
}
}
pub fn cycle_filter(&mut self) {
self.filter = match self.filter {
Filter::All => Filter::Pending,
Filter::Pending => Filter::Completed,
Filter::Completed => Filter::DueToday,
Filter::DueToday => Filter::Overdue,
Filter::Overdue => Filter::All,
_ => Filter::All,
};
let count = self.visible_tasks().len();
self.selected_task_index = if count > 0 { Some(0) } else { None };
}
pub fn cycle_sort(&mut self) {
self.sort = match self.sort {
SortOrder::DueDateAsc => SortOrder::PriorityDesc,
SortOrder::PriorityDesc => SortOrder::CreatedDesc,
SortOrder::CreatedDesc => SortOrder::Alphabetical,
SortOrder::Alphabetical => SortOrder::DueDateAsc,
_ => SortOrder::DueDateAsc,
};
}
pub fn filter_name(&self) -> &'static str {
match self.filter {
Filter::All => "All",
Filter::Pending => "Pending",
Filter::InProgress => "In Progress",
Filter::Completed => "Completed",
Filter::Archived => "Archived",
Filter::DueToday => "Due Today",
Filter::DueThisWeek => "This Week",
Filter::Overdue => "Overdue",
Filter::ByProject(_) => "Project",
Filter::ByTag(_) => "Tag",
Filter::ByPriority(Priority::Low) => "Low Priority",
Filter::ByPriority(Priority::Medium) => "Medium Priority",
Filter::ByPriority(Priority::High) => "High Priority",
Filter::ByPriority(Priority::Urgent) => "Urgent",
}
}
pub fn sort_name(&self) -> &'static str {
match self.sort {
SortOrder::DueDateAsc => "Due Date ↑",
SortOrder::DueDateDesc => "Due Date ↓",
SortOrder::PriorityDesc => "Priority ↓",
SortOrder::PriorityAsc => "Priority ↑",
SortOrder::CreatedDesc => "Newest",
SortOrder::CreatedAsc => "Oldest",
SortOrder::Alphabetical => "A-Z",
}
}
}
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("tasks", &self.tasks.len())
.field("projects", &self.projects.len())
.field("current_view", &self.current_view)
.field("input_mode", &self.input_mode)
.field("focus", &self.focus)
.field("should_quit", &self.should_quit)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::run_migrations;
async fn setup_app() -> App {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
App::new(db).await.unwrap()
}
#[tokio::test]
async fn test_app_new() {
let app = setup_app().await;
assert!(!app.should_quit);
assert_eq!(app.current_view, View::Main);
assert_eq!(app.input_mode, InputMode::Normal);
assert_eq!(app.focus, FocusPanel::TaskList);
}
#[tokio::test]
async fn test_visible_tasks_empty() {
let app = setup_app().await;
let visible = app.visible_tasks();
assert!(visible.is_empty());
}
#[tokio::test]
async fn test_visible_tasks_with_data() {
let mut app = setup_app().await;
let task1 = Task::new("Task 1");
let task2 = Task::new("Task 2");
app.db.insert_task(&task1).await.unwrap();
app.db.insert_task(&task2).await.unwrap();
app.load_data().await.unwrap();
let visible = app.visible_tasks();
assert_eq!(visible.len(), 2);
}
#[tokio::test]
async fn test_select_next_previous_task() {
let mut app = setup_app().await;
for i in 0..3 {
let task = Task::new(&format!("Task {}", i));
app.db.insert_task(&task).await.unwrap();
}
app.load_data().await.unwrap();
assert_eq!(app.selected_task_index, Some(0));
app.select_next_task();
assert_eq!(app.selected_task_index, Some(1));
app.select_next_task();
assert_eq!(app.selected_task_index, Some(2));
app.select_next_task(); assert_eq!(app.selected_task_index, Some(0));
app.select_previous_task(); assert_eq!(app.selected_task_index, Some(2));
}
#[tokio::test]
async fn test_toggle_focus() {
let mut app = setup_app().await;
assert_eq!(app.focus, FocusPanel::TaskList);
app.toggle_focus();
assert_eq!(app.focus, FocusPanel::Sidebar);
app.toggle_focus();
assert_eq!(app.focus, FocusPanel::TaskList);
}
#[tokio::test]
async fn test_cycle_filter() {
let mut app = setup_app().await;
assert_eq!(app.filter, Filter::Pending);
app.cycle_filter();
assert_eq!(app.filter, Filter::Completed);
app.cycle_filter();
assert_eq!(app.filter, Filter::DueToday);
}
#[tokio::test]
async fn test_project_selection() {
let mut app = setup_app().await;
assert!(!app.projects.is_empty());
assert_eq!(app.selected_project_index, 0);
app.select_next_project();
assert_eq!(app.selected_project_index, 1);
app.select_previous_project();
assert_eq!(app.selected_project_index, 0); }
#[tokio::test]
async fn test_task_counts() {
let mut app = setup_app().await;
let mut task1 = Task::new("Task 1");
task1.project_id = Some("inbox".to_string());
app.db.insert_task(&task1).await.unwrap();
let task2 = Task::new("Task 2");
app.db.insert_task(&task2).await.unwrap();
app.load_data().await.unwrap();
assert_eq!(app.total_task_count(), 2);
assert_eq!(app.task_count_for_project("inbox"), 1);
}
}