//! Focus sessions of the pomodoro. The pomodoro counts up: a session runs from its start //! until it is paused or stopped. Its time is added to the task, and counted in //! TimeTagger when it is configured, tagged with the course, the tags and the name of the //! task (`#cs-477 #homework #exercise-set-3`). //! //! TimeTagger never blocks a session: when it fails, the error is returned next to the //! result, to be shown. use chrono::Utc; use thiserror::Error; use tracing::warn; use crate::core::{ controller::ControllerError, models::{ focus::{FocusStart, FocusStop, TimetaggerRecord}, tag::Tag, task::{Task, TaskId}, }, }; /// Longest session that can be added at once const MAX_SESSION_SECONDS: u32 = 24 * 3600; impl super::AppController { /// Starts a session on a task or a subtask pub async fn start_focus(&self, task_id: TaskId) -> Result { let task = self.db.get_task(task_id).await?; let parent = match task.parent_id { Some(parent_id) => Some(self.db.get_task(parent_id).await?), None => None, }; let course = self.db.get_course(task.course_id).await?; let tag_ids = match &parent { Some(parent) if task.tag_ids.is_empty() => parent.tag_ids.clone(), _ => task.tag_ids.clone(), }; let tags: Vec = self .db .get_tags(None) .await? .into_iter() .filter(|tag| tag_ids.contains(&tag.id)) .collect(); let started_at = Utc::now(); let (timetagger, timetagger_error) = match &self.time_tracker { None => (None, None), Some(tracker) => { let course_tag = course.code.as_deref().unwrap_or(&course.name); let description = description(course_tag, &tags, parent.as_ref(), &task); match tracker.start(&description, started_at).await { Ok(record) => (Some(record), None), Err(err) => { warn!("Unable to start a TimeTagger record: {err}"); (None, Some(err.to_string())) } } } }; Ok(FocusStart { course: (&course).into(), task_id: task.id, name: task.name, parent_name: parent.map(|parent| parent.name), tag_ids, time_spent: task.time_spent, started_at, timetagger_enabled: self.time_tracker.is_some(), timetagger, timetagger_error, }) } /// Ends a session (or pauses it: resuming starts a new one): adds its `seconds` to /// the task and stops its TimeTagger record pub async fn stop_focus( &self, task_id: TaskId, seconds: u32, record: Option, ) -> Result { if seconds > MAX_SESSION_SECONDS { return Err(FocusControllerError::TooLong.into()); } let time_spent = self.db.add_time_spent(task_id, seconds as i32).await?; let timetagger_error = match (&self.time_tracker, record) { (Some(tracker), Some(record)) => match tracker.stop(&record, Utc::now()).await { Ok(()) => None, Err(err) => { warn!("Unable to stop the TimeTagger record {}: {err}", record.key); Some(err.to_string()) } }, _ => None, }; Ok(FocusStop { time_spent, timetagger_error, }) } } /// `#cs-477 #homework #exercise-set-3`, the parent task before the subtask fn description(course: &str, tags: &[Tag], parent: Option<&Task>, task: &Task) -> String { let mut words: Vec = Vec::new(); let names = std::iter::once(course) .chain( tags.iter() .map(|tag| tag.slug.as_deref().unwrap_or(&tag.name.en)), ) .chain(parent.map(|parent| parent.name.as_str())) .chain(std::iter::once(task.name.as_str())); for name in names { let word = tag_word(name); if !word.is_empty() && !words.contains(&word) { words.push(word); } } words .iter() .map(|word| format!("#{word}")) .collect::>() .join(" ") } /// A TimeTagger tag is a single lowercase word: "Exercise set 3" -> "exercise-set-3" fn tag_word(name: &str) -> String { name.to_lowercase() .split(|c: char| !c.is_alphanumeric()) .filter(|part| !part.is_empty()) .collect::>() .join("-") } #[derive(Error, Debug)] pub enum FocusControllerError { #[error("A session lasts at most 24 hours")] TooLong, } #[cfg(test)] mod tests { use super::*; use crate::core::models::{localized_string::LocalizedString, tag::TagScope}; fn task(name: &str) -> Task { Task { id: 1, course_id: 1, parent_id: None, name: name.to_owned(), description: None, links: Vec::new(), deadline: None, done: false, tag_ids: Vec::new(), time_spent: 0, subtasks: Vec::new(), } } #[test] fn describes_a_subtask() { let tag = Tag { id: 4, name: LocalizedString { fr: "Devoir".to_owned(), en: "Homework".to_owned(), }, color: "#000".to_owned(), slug: Some("homework".to_owned()), scopes: vec![TagScope::Task], }; let parent = task("Série 3"); assert_eq!( description("CS-477", &[tag], Some(&parent), &task("Exercice 2 (b)")), "#cs-477 #homework #série-3 #exercice-2-b" ); } }