studium/src/core/controller/focus.rs
Antoine Pelletier 5e160ae11c wip
2026-09-29 09:47:27 +02:00

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"
);
}
}