diff --git a/src/components/bottom_panel.rs b/src/components/bottom_panel.rs index ccda1db..ee627a6 100644 --- a/src/components/bottom_panel.rs +++ b/src/components/bottom_panel.rs @@ -41,7 +41,7 @@ pub(crate) fn BottomPanel(display_form: Signal) -> Element { (false, _, false) => "h-[66px]", (false, _, true) => "h-[130px]", (true, Route::ProjectsPage, _) => "h-[130px]", - (true, _, _) => "h-[448px]", + (true, _, _) => "h-[506px]", } ), if expanded() { diff --git a/src/components/mod.rs b/src/components/mod.rs index c820f1d..71255b8 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -13,3 +13,4 @@ pub(crate) mod reoccurrence_input; pub(crate) mod layout; pub(crate) mod navigation_item; pub(crate) mod subtasks_form; +pub(crate) mod task_list_item; diff --git a/src/components/pages/category_today_page.rs b/src/components/pages/category_today_page.rs index 40c830d..7122eb3 100644 --- a/src/components/pages/category_today_page.rs +++ b/src/components/pages/category_today_page.rs @@ -6,11 +6,12 @@ use crate::query::QueryValue; use chrono::{Local, Locale}; use dioxus::prelude::*; use dioxus_query::prelude::QueryResult; +use crate::components::task_list_item::TaskListItem; #[component] pub(crate) fn CategoryTodayPage() -> Element { let today_date = Local::now().date_naive(); - + let calendar_tasks_query = use_tasks_with_subtasks_in_category_query(Category::Calendar { date: today_date, reoccurrence: None, @@ -26,48 +27,36 @@ pub(crate) fn CategoryTodayPage() -> Element { class: "pt-4 flex flex-col gap-8", match long_term_tasks_query_result.value() { QueryResult::Ok(QueryValue::TasksWithSubtasks(tasks)) - | QueryResult::Loading(Some(QueryValue::TasksWithSubtasks(tasks))) => rsx! { - div { - class: "flex flex-col gap-4", + | QueryResult::Loading(Some(QueryValue::TasksWithSubtasks(tasks))) => { + let mut tasks = tasks.clone(); + tasks.sort(); + rsx! { div { - class: "px-8 flex flex-row items-center gap-2 font-bold", - i { - class: "fa-solid fa-water text-xl w-6 text-center" + class: "flex flex-col gap-4", + div { + class: "px-8 flex flex-row items-center gap-2 font-bold", + i { + class: "fa-solid fa-water text-xl w-6 text-center" + } + div { + class: "mt-1", + "Long-term" + } } div { - class: "mt-1", - "Long-term" - } - } - div { - for task in tasks { - div { - key: "{task.task().id()}", - class: format!( - "px-8 pt-5 {} flex flex-row gap-4", - if task.task().deadline().is_some() { - "pb-0.5" - } else { - "pb-5" - } - ), + for task in tasks { div { - class: "flex flex-col", - div { - class: "mt grow font-medium", - {task.task().title()} - }, - div { - class: "flex flex-row gap-3", - if let Some(deadline) = task.task().deadline() { - div { - class: "text-sm text-zinc-400", - i { - class: "fa-solid fa-bomb" - }, - {deadline.format(" %m. %d.").to_string()} - } + key: "{task.task().id()}", + class: format!( + "px-8 pt-5 {} flex flex-row gap-4", + if task.task().deadline().is_some() { + "pb-0.5" + } else { + "pb-5" } + ), + TaskListItem { + task: task.clone() } } } diff --git a/src/components/pages/projects_page.rs b/src/components/pages/projects_page.rs index 028181c..94a0688 100644 --- a/src/components/pages/projects_page.rs +++ b/src/components/pages/projects_page.rs @@ -12,20 +12,24 @@ pub(crate) fn ProjectsPage() -> Element { rsx! { match projects_query.result().value() { QueryResult::Ok(QueryValue::Projects(projects)) - | QueryResult::Loading(Some(QueryValue::Projects(projects))) => rsx! { - div { - class: "flex flex-col", - for project in projects.clone() { - div { - key: "{project.id()}", - class: format!( - "px-8 py-4 select-none {}", - if project_being_edited().is_some_and(|p| p.id() == project.id()) { - "bg-zinc-700" - } else { "" } - ), - onclick: move |_| project_being_edited.set(Some(project.clone())), - {project.title()} + | QueryResult::Loading(Some(QueryValue::Projects(projects))) => { + let mut projects = projects.clone(); + projects.sort(); + rsx! { + div { + class: "flex flex-col", + for project in projects { + div { + key: "{project.id()}", + class: format!( + "px-8 py-4 select-none {}", + if project_being_edited().is_some_and(|p| p.id() == project.id()) { + "bg-zinc-700" + } else { "" } + ), + onclick: move |_| project_being_edited.set(Some(project.clone())), + {project.title()} + } } } } diff --git a/src/components/subtasks_form.rs b/src/components/subtasks_form.rs index 3e16e28..0548516 100644 --- a/src/components/subtasks_form.rs +++ b/src/components/subtasks_form.rs @@ -64,8 +64,10 @@ pub(crate) fn SubtasksForm(task: Task) -> Element { match subtasks_query.result().value() { QueryResult::Ok(QueryValue::Subtasks(subtasks)) | QueryResult::Loading(Some(QueryValue::Subtasks(subtasks))) => { + let mut subtasks = subtasks.clone(); + subtasks.sort(); rsx! { - for subtask in subtasks.clone() { + for subtask in subtasks { div { key: "{subtask.id()}", class: "flex flex-row items-center gap-3", diff --git a/src/components/task_list.rs b/src/components/task_list.rs index b8d3e78..cd6400d 100644 --- a/src/components/task_list.rs +++ b/src/components/task_list.rs @@ -4,6 +4,7 @@ use dioxus::core_macro::rsx; use dioxus::dioxus_core::Element; use dioxus::prelude::*; use dioxus_query::prelude::use_query_client; +use crate::components::task_list_item::TaskListItem; use crate::query::{QueryErrors, QueryKey, QueryValue}; use crate::server::tasks::complete_task; @@ -12,6 +13,8 @@ pub(crate) fn TaskList(tasks: Vec, class: Option<&'static str> let query_client = use_query_client::(); let mut task_being_edited = use_context::>>(); + tasks.sort(); + rsx! { div { class: format!("flex flex-col {}", class.unwrap_or("")), @@ -74,50 +77,8 @@ pub(crate) fn TaskList(tasks: Vec, class: Option<&'static str> } } }, - div { - class: "flex flex-col", - div { - class: "mt-1 grow font-medium", - {task.task().title()} - }, - div { - class: "flex flex-row gap-4", - if let Some(deadline) = task.task().deadline() { - div { - class: "text-sm text-zinc-400", - i { - class: "fa-solid fa-bomb" - }, - {deadline.format(" %m. %-d.").to_string()} - } - } - if let Category::Calendar { time, .. } = task.task().category() { - if let Some(calendar_time) = time { - div { - class: "text-sm text-zinc-400", - i { - class: "fa-solid fa-clock" - }, - {calendar_time.time().format(" %k:%M").to_string()} - } - } - } - if !task.subtasks().is_empty() { - div { - class: "text-sm text-zinc-400", - i { - class: "fa-solid fa-list-check" - }, - {format!( - " {}/{}", - task.subtasks().iter() - .filter(|subtask| subtask.is_completed()) - .count(), - task.subtasks().len() - )} - } - } - } + TaskListItem { + task: task.clone() } } } diff --git a/src/components/task_list_item.rs b/src/components/task_list_item.rs new file mode 100644 index 0000000..c8835b9 --- /dev/null +++ b/src/components/task_list_item.rs @@ -0,0 +1,61 @@ +use chrono::{Datelike, Local}; +use crate::models::category::Category; +use crate::models::task::TaskWithSubtasks; +use dioxus::core_macro::rsx; +use dioxus::dioxus_core::Element; +use dioxus::prelude::*; + +#[component] +pub(crate) fn TaskListItem(task: TaskWithSubtasks) -> Element { + rsx! { + div { + class: "flex flex-col", + div { + class: "mt-1 grow font-medium", + {task.task().title()} + }, + div { + class: "flex flex-row gap-4", + if let Some(deadline) = task.task().deadline() { + div { + class: "text-sm text-zinc-400", + i { + class: "fa-solid fa-bomb" + }, + {deadline.format(if deadline.year() == Local::now().year() { + " %m. %-d." + } else { + " %m. %-d. %Y" + }).to_string()} + } + } + if let Category::Calendar { time, .. } = task.task().category() { + if let Some(calendar_time) = time { + div { + class: "text-sm text-zinc-400", + i { + class: "fa-solid fa-clock" + }, + {calendar_time.time().format(" %k:%M").to_string()} + } + } + } + if !task.subtasks().is_empty() { + div { + class: "text-sm text-zinc-400", + i { + class: "fa-solid fa-list-check" + }, + {format!( + " {}/{}", + task.subtasks().iter() + .filter(|subtask| subtask.is_completed()) + .count(), + task.subtasks().len() + )} + } + } + } + } + } +} diff --git a/src/errors/subtask_error.rs b/src/errors/subtask_error.rs index 3297208..505626d 100644 --- a/src/errors/subtask_error.rs +++ b/src/errors/subtask_error.rs @@ -53,6 +53,13 @@ impl From for SubtaskError { } } +impl From> for ErrorVec { + fn from(error_vec: ErrorVec) -> Self { + Vec::from(error_vec).into_iter() + .map(SubtaskError::Error).collect::>().into() + } +} + // Has to be implemented for Dioxus server functions. impl Display for SubtaskError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { diff --git a/src/main.rs b/src/main.rs index a7a75b6..31ce965 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod route; mod schema; mod server; mod query; +mod utils; use components::app::App; use dioxus::prelude::*; diff --git a/src/models/project.rs b/src/models/project.rs index fb8da72..8dcd419 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use chrono::NaiveDateTime; use crate::schema::projects; use diesel::prelude::*; @@ -35,6 +36,20 @@ impl Project { } } +impl Eq for Project {} + +impl PartialOrd for Project { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Project { + fn cmp(&self, other: &Self) -> Ordering { + self.title().cmp(other.title()) + } +} + #[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] #[diesel(table_name = projects)] pub struct NewProject { diff --git a/src/models/subtask.rs b/src/models/subtask.rs index 7aa9e84..ab15e63 100644 --- a/src/models/subtask.rs +++ b/src/models/subtask.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use crate::models::task::Task; use crate::schema::subtasks; use chrono::NaiveDateTime; @@ -48,6 +49,21 @@ impl Subtask { } } +impl Eq for Subtask {} + +impl PartialOrd for Subtask { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Subtask { + fn cmp(&self, other: &Self) -> Ordering { + self.is_completed().cmp(&other.is_completed()) + .then(self.created_at().cmp(&other.created_at())) + } +} + #[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] #[diesel(table_name = subtasks)] pub struct NewSubtask { diff --git a/src/models/task.rs b/src/models/task.rs index 7b4d7cc..a925b6e 100644 --- a/src/models/task.rs +++ b/src/models/task.rs @@ -1,10 +1,12 @@ -use chrono::NaiveDateTime; use crate::models::category::Category; +use crate::models::subtask::Subtask; use crate::schema::tasks; +use crate::utils::reverse_ord_option::ReverseOrdOption; +use chrono::NaiveDateTime; use diesel::prelude::*; use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; use validator::Validate; -use crate::models::subtask::Subtask; const TITLE_LENGTH_MIN: u64 = 1; const TITLE_LENGTH_MAX: u64 = 255; @@ -52,6 +54,40 @@ impl Task { } } +impl Eq for Task {} + +impl PartialOrd for Task { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Task { + fn cmp(&self, other: &Self) -> Ordering { + match (&self.category, &other.category) { + (Category::Inbox, Category::Inbox) => self.created_at.cmp(&other.created_at), + ( + Category::Calendar { date: self_date, time: self_time, .. }, + Category::Calendar { date: other_date, time: other_time, .. } + ) => self_date.cmp(other_date) + .then(ReverseOrdOption::from( + &self_time.as_ref().map(|calendar_time| calendar_time.time()) + ).cmp(&ReverseOrdOption::from( + &other_time.as_ref().map(|calendar_time| calendar_time.time()) + ))) + .then(ReverseOrdOption::from(&self.deadline()).cmp( + &ReverseOrdOption::from(&other.deadline()) + )) + .then(self.created_at.cmp(&other.created_at)), + (Category::Done, Category::Done) | (Category::Trash, Category::Trash) + => self.updated_at.cmp(&other.updated_at).reverse(), + (_, _) => ReverseOrdOption::from(&self.deadline()).cmp( + &ReverseOrdOption::from(&other.deadline()) + ).then(self.created_at.cmp(&other.created_at)), + } + } +} + #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] pub struct TaskWithSubtasks { task: Task, @@ -72,6 +108,20 @@ impl TaskWithSubtasks { } } +impl Eq for TaskWithSubtasks {} + +impl PartialOrd for TaskWithSubtasks { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TaskWithSubtasks { + fn cmp(&self, other: &Self) -> Ordering { + self.task().cmp(other.task()) + } +} + #[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] #[diesel(table_name = tasks)] pub struct NewTask { diff --git a/src/server/subtasks.rs b/src/server/subtasks.rs index 6108575..844a7a3 100644 --- a/src/server/subtasks.rs +++ b/src/server/subtasks.rs @@ -6,6 +6,7 @@ use crate::server::database_connection::establish_database_connection; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; use dioxus::prelude::*; use validator::Validate; +use crate::server::tasks::trigger_task_updated_at; #[server] pub(crate) async fn create_subtask(new_subtask: NewSubtask) @@ -25,6 +26,9 @@ pub(crate) async fn create_subtask(new_subtask: NewSubtask) .returning(Subtask::as_returning()) .get_result(&mut connection) .map_err::, _>(|error| vec![error.into()].into())?; + + trigger_task_updated_at(new_subtask.task_id).await + .map_err::, _>(|error_vec| error_vec.into())?; Ok(created_subtask) } @@ -35,17 +39,13 @@ pub(crate) async fn get_subtasks_of_task(filtered_task_id: i32) use crate::schema::subtasks::dsl::*; let mut connection = establish_database_connection() - .map_err::, _>( - |_| vec![Error::ServerInternal].into() - )?; + .map_err::, _>(|_| vec![Error::ServerInternal].into())?; let results = subtasks .select(Subtask::as_select()) .filter(task_id.eq(filtered_task_id)) .load::(&mut connection) - .map_err::, _>( - |_| vec![Error::ServerInternal].into() - )?; + .map_err::, _>(|_| vec![Error::ServerInternal].into())?; Ok(results) } @@ -73,27 +73,28 @@ pub(crate) async fn edit_subtask(subtask_id: i32, new_subtask: NewSubtask) .get_result(&mut connection) .map_err::, _>(|error| vec![error.into()].into())?; + trigger_task_updated_at(new_subtask.task_id).await + .map_err::, _>(|error_vec| error_vec.into())?; + Ok(updated_task) } #[server] pub(crate) async fn restore_subtasks_of_task(filtered_task_id: i32) -> Result< Vec, - ServerFnError> + ServerFnError> > { use crate::schema::subtasks::dsl::*; let mut connection = establish_database_connection() - .map_err::, _>( - |_| vec![SubtaskError::Error(Error::ServerInternal)].into() - )?; + .map_err::, _>(|_| vec![Error::ServerInternal].into())?; let updated_subtasks = diesel::update(subtasks) .filter(task_id.eq(filtered_task_id)) .set(is_completed.eq(false)) .returning(Subtask::as_returning()) .get_results(&mut connection) - .map_err::, _>(|error| vec![error.into()].into())?; + .map_err::, _>(|error| vec![error.into()].into())?; Ok(updated_subtasks) } @@ -108,8 +109,12 @@ pub(crate) async fn delete_subtask(subtask_id: i32) let mut connection = establish_database_connection() .map_err::, _>(|_| vec![Error::ServerInternal].into())?; - diesel::delete(subtasks.filter(id.eq(subtask_id))).execute(&mut connection) + let deleted_subtask = diesel::delete(subtasks.filter(id.eq(subtask_id))) + .returning(Subtask::as_returning()) + .get_result(&mut connection) .map_err::, _>(|error| vec![error.into()].into())?; + trigger_task_updated_at(deleted_subtask.task_id()).await?; + Ok(()) } diff --git a/src/server/tasks.rs b/src/server/tasks.rs index fb1d747..192726f 100644 --- a/src/server/tasks.rs +++ b/src/server/tasks.rs @@ -1,4 +1,4 @@ -use chrono::{Datelike, Days, Months, NaiveDate}; +use chrono::{Datelike, Days, Local, Months, NaiveDate}; use crate::errors::error::Error; use crate::errors::error_vec::ErrorVec; use crate::models::task::{NewTask, Task, TaskWithSubtasks}; @@ -80,7 +80,7 @@ pub(crate) async fn get_tasks_with_subtasks_in_category(filtered_category: Categ ServerFnError> > { use crate::schema::tasks; - + let mut connection = establish_database_connection() .map_err::, _>(|_| vec![Error::ServerInternal].into())?; @@ -160,8 +160,7 @@ pub(crate) async fn complete_task(task_id: i32) -> Result, _>(|_| vec![Error::ServerInternal].into())?; + restore_subtasks_of_task(task_id).await?; } else { new_task.category = Category::Done; } @@ -187,3 +186,21 @@ pub(crate) async fn delete_task(task_id: i32) Ok(()) } + +pub(crate) async fn trigger_task_updated_at(task_id: i32) -> Result> { + use crate::schema::tasks::dsl::*; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![Error::ServerInternal].into() + )?; + + let updated_task = diesel::update(tasks) + .filter(id.eq(task_id)) + .set(updated_at.eq(Local::now().naive_local())) + .returning(Task::as_returning()) + .get_result(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(updated_task) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..84f597f --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1 @@ +pub(crate) mod reverse_ord_option; diff --git a/src/utils/reverse_ord_option.rs b/src/utils/reverse_ord_option.rs new file mode 100644 index 0000000..fa5f9f3 --- /dev/null +++ b/src/utils/reverse_ord_option.rs @@ -0,0 +1,31 @@ +use std::cmp::Ordering; + +/* The default ordering of `Option`s is `None` being less than `Some`. The purpose of this struct is + to reverse that. */ +#[derive(PartialEq)] +pub(crate) struct ReverseOrdOption<'a, T>(&'a Option); + +impl<'a, T: Ord> Eq for ReverseOrdOption<'a, T> {} + +impl<'a, T: Ord> PartialOrd for ReverseOrdOption<'a, T> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl<'a, T: Ord> Ord for ReverseOrdOption<'a, T> { + fn cmp(&self, other: &Self) -> Ordering { + match (self.0.as_ref(), other.0.as_ref()) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(self_time), Some(other_time)) => self_time.cmp(other_time) + } + } +} + +impl<'a, T> From<&'a Option> for ReverseOrdOption<'a, T> { + fn from(value: &'a Option) -> Self { + ReverseOrdOption(value) + } +}