use chrono::Local;
use core::fmt::Formatter;
use getset::{Getters, MutGetters, Setters};
use merge::Merge;
use serde_derive::{Deserialize, Serialize};
use std::fmt::Display;
use typed_builder::TypedBuilder;
use ulid::Ulid;
use crate::{
calculate_duration,
domain::{
status::ActivityStatus,
time::{duration_to_str, PaceDateTime, PaceDuration},
},
PaceResult,
};
#[derive(Debug, TypedBuilder, Getters, Setters, MutGetters, Clone, Eq, PartialEq, Default)]
#[getset(get = "pub", get_mut = "pub", set = "pub")]
pub struct ActivityItem {
guid: ActivityGuid,
activity: Activity,
}
impl ActivityItem {
pub fn new(guid: ActivityGuid, activity: Activity) -> Self {
Self { guid, activity }
}
pub fn into_parts(self) -> (ActivityGuid, Activity) {
(self.guid, self.activity)
}
}
impl From<Activity> for ActivityItem {
fn from(activity: Activity) -> Self {
Self {
guid: ActivityGuid::default(),
activity,
}
}
}
impl From<(ActivityGuid, Activity)> for ActivityItem {
fn from((guid, activity): (ActivityGuid, Activity)) -> Self {
Self { guid, activity }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum ActivityKind {
#[default]
Activity,
Task,
Intermission,
PomodoroWork,
PomodoroIntermission,
}
impl ActivityKind {
#[must_use]
pub fn is_activity(&self) -> bool {
matches!(self, Self::Activity)
}
#[must_use]
pub fn is_task(&self) -> bool {
matches!(self, Self::Task)
}
#[must_use]
pub fn is_intermission(&self) -> bool {
matches!(self, Self::Intermission)
}
#[must_use]
pub fn is_pomodoro_work(&self) -> bool {
matches!(self, Self::PomodoroWork)
}
#[must_use]
pub fn is_pomodoro_intermission(&self) -> bool {
matches!(self, Self::PomodoroIntermission)
}
pub fn to_symbol(&self) -> &'static str {
match self {
Self::Activity => "📆",
Self::Task => "📋",
Self::Intermission => "⏸️",
Self::PomodoroWork => "🍅⏲️",
Self::PomodoroIntermission => "🍅⏸️",
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
enum PomodoroCycle {
Work(usize), #[default]
Intermission,
}
#[derive(
Debug,
Serialize,
Deserialize,
TypedBuilder,
Getters,
Setters,
MutGetters,
Clone,
Eq,
PartialEq,
Default,
)]
#[getset(get = "pub", set = "pub", get_mut = "pub")]
#[derive(Merge)]
pub struct Activity {
#[builder(default, setter(into))]
#[getset(get = "pub", get_mut = "pub")]
#[serde(skip_serializing_if = "Option::is_none")]
#[merge(strategy = crate::util::overwrite_left_with_right)]
category: Option<String>,
#[builder(setter(into))]
#[merge(strategy = crate::util::overwrite_left_with_right)]
description: String,
#[builder(default, setter(into))]
#[getset(get = "pub")]
#[merge(skip)]
begin: PaceDateTime,
#[builder(default)]
#[serde(flatten, skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", get_mut = "pub")]
#[merge(strategy = crate::util::overwrite_left_with_right)]
activity_end_options: Option<ActivityEndOptions>,
#[builder(default)]
#[merge(skip)]
kind: ActivityKind,
#[builder(default, setter(into))]
#[serde(flatten, skip_serializing_if = "Option::is_none")]
#[merge(strategy = crate::util::overwrite_left_with_right)]
activity_kind_options: Option<ActivityKindOptions>,
#[builder(default, setter(into))]
#[serde(skip_serializing_if = "Option::is_none")]
#[merge(strategy = crate::util::overwrite_left_with_right)]
pomodoro_cycle_options: Option<PomodoroCycle>,
#[serde(default)]
#[builder(default)]
#[merge(strategy = crate::util::overwrite_left_with_right)]
status: ActivityStatus,
}
#[derive(
Debug, Serialize, Deserialize, TypedBuilder, Getters, Setters, MutGetters, Clone, Eq, PartialEq,
)]
#[getset(get = "pub")]
pub struct ActivityEndOptions {
#[builder(default)]
#[getset(get = "pub")]
end: PaceDateTime,
#[builder(default)]
#[getset(get = "pub")]
duration: PaceDuration,
}
impl ActivityEndOptions {
pub fn new(end: PaceDateTime, duration: PaceDuration) -> Self {
Self { end, duration }
}
}
#[derive(
Debug,
Serialize,
Deserialize,
TypedBuilder,
Getters,
Setters,
MutGetters,
Clone,
Eq,
PartialEq,
Default,
)]
#[getset(get = "pub", set = "pub", get_mut = "pub")]
#[derive(Merge)]
#[serde(rename_all = "kebab-case")]
pub struct ActivityKindOptions {
#[serde(skip_serializing_if = "Option::is_none")]
#[merge(skip)]
parent_id: Option<ActivityGuid>,
}
impl ActivityKindOptions {
pub fn with_parent_id(parent_id: ActivityGuid) -> Self {
Self {
parent_id: parent_id.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Ord, PartialEq, PartialOrd, Eq, Copy, Hash)]
pub struct ActivityGuid(Ulid);
impl Display for ActivityGuid {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl Default for ActivityGuid {
fn default() -> Self {
Self(Ulid::new())
}
}
impl Display for Activity {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let rel_time = match self.begin.and_local_timezone(Local) {
chrono::LocalResult::Single(time) => duration_to_str(time),
chrono::LocalResult::None | chrono::LocalResult::Ambiguous(_, _) => {
format!("at {}", self.begin)
}
};
let nop_cat = "Uncategorized".to_string();
write!(
f,
"{} Activity: \"{}\" ({}) started {}",
self.kind.to_symbol(),
self.description(),
self.category().as_ref().unwrap_or(&nop_cat),
rel_time,
)
}
}
#[cfg(feature = "sqlite")]
impl rusqlite::types::FromSql for ActivityGuid {
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
let bytes = <[u8; 16]>::column_result(value)?;
Ok(Self(Ulid::from(u128::from_be_bytes(bytes))))
}
}
#[cfg(feature = "sqlite")]
impl rusqlite::types::ToSql for ActivityGuid {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
Ok(rusqlite::types::ToSqlOutput::from(self.0.to_string()))
}
}
impl Activity {
pub fn new_from_self(&self) -> Self {
Self::builder()
.description(self.description.clone())
.category(self.category.clone())
.kind(self.kind)
.activity_kind_options(self.activity_kind_options.clone())
.pomodoro_cycle_options(self.pomodoro_cycle_options)
.build()
}
pub fn is_held(&self) -> bool {
self.status.is_held()
}
#[must_use]
pub fn is_active(&self) -> bool {
self.activity_end_options().is_none()
&& (!self.kind.is_intermission() || !self.kind.is_pomodoro_intermission())
&& self.status.is_active()
}
pub fn make_active(&mut self) {
self.status = ActivityStatus::Active;
}
pub fn make_inactive(&mut self) {
self.status = ActivityStatus::Inactive;
}
pub fn archive(&mut self) {
if !self.is_active() && self.has_ended() {
self.status = ActivityStatus::Archived;
}
}
pub fn unarchive(&mut self) {
if self.is_archived() {
self.status = ActivityStatus::Unarchived;
}
}
#[must_use]
pub fn is_active_intermission(&self) -> bool {
self.activity_end_options().is_none()
&& (self.kind.is_intermission() || self.kind.is_pomodoro_intermission())
&& self.status.is_active()
}
#[must_use]
pub fn is_archived(&self) -> bool {
self.status.is_archived()
}
#[must_use]
pub fn is_inactive(&self) -> bool {
self.status.is_inactive()
}
#[must_use]
pub fn has_ended(&self) -> bool {
self.activity_end_options().is_some()
&& (!self.kind.is_intermission() || !self.kind.is_pomodoro_intermission())
&& !self.is_archived()
&& self.status.is_ended()
}
pub fn end_activity(&mut self, end_opts: ActivityEndOptions) {
self.activity_end_options = Some(end_opts);
self.status = ActivityStatus::Ended;
}
pub fn end_activity_with_duration_calc(
&mut self,
begin: PaceDateTime,
end: PaceDateTime,
) -> PaceResult<()> {
let end_opts = ActivityEndOptions::new(end, calculate_duration(&begin, end)?);
self.end_activity(end_opts);
Ok(())
}
#[must_use]
pub fn parent_id(&self) -> Option<ActivityGuid> {
self.activity_kind_options
.as_ref()
.and_then(|opts| opts.parent_id)
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use chrono::NaiveDateTime;
use super::*;
#[test]
fn test_parse_single_toml_activity_passes() {
let toml = r#"
category = "Work"
description = "This is an example activity"
end = "2021-08-01T12:00:00"
begin = "2021-08-01T10:00:00"
duration = 5
kind = "activity"
"#;
let activity: Activity = toml::from_str(toml).unwrap();
assert_eq!(activity.category.as_ref().unwrap(), "Work");
assert_eq!(activity.description, "This is an example activity");
let ActivityEndOptions { end, duration } = activity.activity_end_options().clone().unwrap();
assert_eq!(
end,
PaceDateTime::from(
NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap()
)
);
assert_eq!(
activity.begin,
PaceDateTime::from(
NaiveDateTime::parse_from_str("2021-08-01T10:00:00", "%Y-%m-%dT%H:%M:%S").unwrap()
)
);
assert_eq!(duration, PaceDuration::from_str("5").unwrap());
assert_eq!(activity.kind, ActivityKind::Activity);
}
#[test]
fn test_parse_single_toml_intermission_passes() {
let toml = r#"
end = "2021-08-01T12:00:00"
begin = "2021-08-01T10:00:00"
description = "This is an example activity"
duration = 50
kind = "intermission"
parent-id = "01F9Z4Z3Z3Z3Z4Z3Z3Z3Z3Z3Z4"
"#;
let activity: Activity = toml::from_str(toml).unwrap();
let ActivityEndOptions { end, duration } = activity.activity_end_options().clone().unwrap();
assert_eq!(
end,
PaceDateTime::from(
NaiveDateTime::parse_from_str("2021-08-01T12:00:00", "%Y-%m-%dT%H:%M:%S").unwrap()
)
);
assert_eq!(duration, PaceDuration::from_str("50").unwrap());
assert_eq!(
activity.begin,
PaceDateTime::from(
NaiveDateTime::parse_from_str("2021-08-01T10:00:00", "%Y-%m-%dT%H:%M:%S").unwrap()
)
);
assert_eq!(activity.kind, ActivityKind::Intermission);
assert_eq!(
activity
.activity_kind_options
.unwrap()
.parent_id
.unwrap()
.to_string(),
"01F9Z4Z3Z3Z3Z4Z3Z3Z3Z3Z3Z4"
);
}
}