183 lines
5.8 KiB
Rust
183 lines
5.8 KiB
Rust
//! 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<FocusStart, ControllerError> {
|
|
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<Tag> = 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<TimetaggerRecord>,
|
|
) -> Result<FocusStop, ControllerError> {
|
|
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<String> = 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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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"
|
|
);
|
|
}
|
|
}
|