acari-lib 0.1.12

Pragmatic client for the mite timetracking API
Documentation
use std::collections::HashMap;

use crate::{
  model::{Account, AccountId, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, User, UserId},
  AcariError, DateSpan, Day,
};
use chrono::offset::Local;
use chrono::{DateTime, Datelike, NaiveDate, Utc, Weekday};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct EverhourError {
  pub code: u16,
  pub message: String,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct EverhourCurrency {
  pub code: String,
  pub name: String,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTeam {
  pub id: AccountId,
  pub name: String,
  pub currency_details: EverhourCurrency,
  #[serde(with = "date_format")]
  pub created_at: DateTime<Utc>,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourUser {
  pub id: UserId,
  pub name: String,
  pub email: String,
  pub status: String,
  pub role: String,
  pub headline: String,
  pub is_suspended: bool,
  pub team: EverhourTeam,
  #[serde(with = "date_format")]
  pub created_at: DateTime<Utc>,
}

impl From<EverhourUser> for Account {
  fn from(f: EverhourUser) -> Self {
    Account {
      id: f.team.id,
      name: f.team.name.clone(),
      title: f.team.name,
      currency: f.team.currency_details.code,
      created_at: f.team.created_at,
    }
  }
}

impl From<EverhourUser> for User {
  fn from(f: EverhourUser) -> Self {
    User {
      id: f.id,
      name: f.name,
      email: f.email,
      role: f.role,
      note: f.headline,
      language: "".to_string(),
      archived: f.is_suspended,
      created_at: f.created_at,
    }
  }
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourProject {
  pub id: ProjectId,
  pub name: String,
  pub workspace_id: CustomerId,
  pub workspace_name: String,
  pub status: String,
  #[serde(with = "date_format")]
  pub created_at: DateTime<Utc>,
}

impl From<EverhourProject> for Customer {
  fn from(f: EverhourProject) -> Self {
    Customer {
      id: f.workspace_id,
      name: f.workspace_name,
      note: "".to_string(),
      archived: f.status != "open",
      created_at: f.created_at,
    }
  }
}

impl From<EverhourProject> for Project {
  fn from(f: EverhourProject) -> Self {
    Project {
      id: f.id,
      name: f.name,
      note: "".to_string(),
      customer_id: f.workspace_id,
      customer_name: f.workspace_name,
      archived: f.status != "open",
      created_at: f.created_at,
    }
  }
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTask {
  pub id: ServiceId,
  pub name: String,
  pub status: String,
  pub iteration: String,
  pub projects: Vec<ProjectId>,
  #[serde(with = "date_format")]
  pub created_at: DateTime<Utc>,
}

impl From<EverhourTask> for Service {
  fn from(f: EverhourTask) -> Self {
    Service {
      id: f.id,
      name: f.name,
      note: f.iteration,
      archived: f.status != "open",
      billable: true,
      created_at: f.created_at,
    }
  }
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTimeEntry {
  pub date: NaiveDate,
  #[serde(default)]
  pub comment: String,
  pub task: Option<EverhourTask>,
  #[serde(with = "minutes_in_seconds")]
  pub time: Minutes,
  pub user: UserId,
  pub is_locked: bool,
  #[serde(with = "date_format")]
  pub created_at: DateTime<Utc>,
}

impl EverhourTimeEntry {
  pub fn into_entry(self, project_map: &HashMap<ProjectId, EverhourProject>, user: &EverhourUser) -> Option<TimeEntry> {
    match self.task {
      Some(task) => match task.projects.iter().filter_map(|p| project_map.get(p)).next() {
        Some(project) => Some(TimeEntry {
          id: build_time_entry_id(&self.user, &task.id, &self.date),
          date_at: self.date,
          minutes: self.time,
          customer_id: project.workspace_id.clone(),
          customer_name: project.workspace_name.clone(),
          project_id: project.id.clone(),
          project_name: project.name.clone(),
          service_id: task.id,
          service_name: task.name,
          user_id: user.id.clone(),
          user_name: user.name.clone(),
          note: self.comment,
          billable: true,
          locked: self.is_locked,
          created_at: self.created_at,
        }),
        _ => None,
      },
      _ => None,
    }
  }
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourCreateTimeRecord {
  pub date: NaiveDate,
  #[serde(with = "minutes_in_seconds")]
  pub time: Minutes,
  pub user: UserId,
  pub comment: String,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourUserSimple {
  pub id: UserId,
  pub name: String,
  pub email: String,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTimer {
  pub status: String,
  pub task: Option<EverhourTask>,
  pub user: Option<EverhourUserSimple>,
  pub duration: Option<u32>,
  pub today: Option<u32>,
  #[serde(with = "date_format", default = "default_started_at")]
  pub started_at: DateTime<Utc>,
  pub comment: Option<String>,
}

fn default_started_at() -> DateTime<Utc> {
  Utc::now()
}

pub fn day_query_param(day: &Day) -> String {
  match day {
    Day::Today => format!("{}", Local::now().naive_local().date()),
    Day::Yesterday => format!("{}", Local::now().naive_local().date().pred()),
    Day::Date(date) => format!("{}", date),
  }
}

pub fn date_span_query_param(span: &DateSpan) -> String {
  match span {
    DateSpan::ThisWeek => {
      let now = Local::now().naive_local().date();
      let year = now.year();
      let week = now.iso_week().week();

      let mon = NaiveDate::from_isoywd(year, week, Weekday::Mon);
      let sun = NaiveDate::from_isoywd(year, week, Weekday::Sun);

      format!("from={}&to={}", mon, sun)
    }
    DateSpan::LastWeek => {
      let now = Local::now().naive_local().date();

      let sun = NaiveDate::from_isoywd(now.year(), now.iso_week().week(), Weekday::Mon).succ();
      let mon = NaiveDate::from_isoywd(sun.year(), sun.iso_week().week(), Weekday::Mon);

      format!("from={}&to={}", mon, sun)
    }
    DateSpan::ThisMonth => {
      let now = Local::now().naive_local().date();
      let year = now.year();
      let month = now.month();

      let start = NaiveDate::from_ymd(year, month, 1);
      let end = if month == 12 {
        NaiveDate::from_ymd(year + 1, 1, 1).pred()
      } else {
        NaiveDate::from_ymd(year, month + 1, 1).pred()
      };

      format!("from={}&to={}", start, end)
    }
    DateSpan::LastMonth => {
      let now = Local::now().naive_local().date();
      let end = NaiveDate::from_ymd(now.year(), now.month(), 1).pred();
      let year = end.year();
      let month = end.month();

      let start = if month == 1 {
        NaiveDate::from_ymd(year - 1, 12, 1)
      } else {
        NaiveDate::from_ymd(year, month - 1, 1)
      };

      format!("from={}&to={}", start, end)
    }
    DateSpan::Day(date) => format!("from={}&to={}", day_query_param(&date), day_query_param(&date)),
    DateSpan::FromTo(from, to) => format!("from={}&to={}", from, to),
  }
}

pub fn build_time_entry_id(user_id: &UserId, service_id: &ServiceId, date: &NaiveDate) -> TimeEntryId {
  TimeEntryId::Str(format!("{}|{}|{}", user_id.str_encoded(), service_id.str_encoded(), date))
}

pub fn parse_time_entry_id(time_entry_id: &TimeEntryId) -> Result<(UserId, ServiceId, NaiveDate), AcariError> {
  let parts: Vec<&str> = match time_entry_id {
    TimeEntryId::Str(s) => s.split('|').collect(),
    _ => return Err(AcariError::InternalError("Invalid time entry id (no number)".to_string())),
  };
  if parts.len() != 3 {
    return Err(AcariError::InternalError("Invalid time entry id (invalid parts)".to_string()));
  }
  let user_id = UserId::parse_encoded(&parts[0])?;
  let service_id = ServiceId::parse_encoded(&parts[1])?;
  let date = NaiveDate::parse_from_str(parts[2], "%Y-%m-%d")?;

  Ok((user_id, service_id, date))
}

mod date_format {
  use chrono::{DateTime, TimeZone, Utc};
  use serde::{self, Deserialize, Deserializer, Serializer};

  const FORMAT: &str = "%Y-%m-%d %H:%M:%S";

  pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
  where
    S: Serializer,
  {
    let s = format!("{}", date.format(FORMAT));
    serializer.serialize_str(&s)
  }

  pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
  where
    D: Deserializer<'de>,
  {
    let s = String::deserialize(deserializer)?;
    Utc
      .datetime_from_str(&s, FORMAT)
      .or_else(|_| Utc.datetime_from_str(&format!("{} 00:00:00", &s), FORMAT))
      .map_err(serde::de::Error::custom)
  }
}

mod minutes_in_seconds {
  use crate::model::Minutes;
  use serde::{self, Deserialize, Deserializer, Serializer};

  pub fn serialize<S>(minutes: &Minutes, serializer: S) -> Result<S::Ok, S::Error>
  where
    S: Serializer,
  {
    serializer.serialize_u32(minutes.0 * 60)
  }

  pub fn deserialize<'de, D>(deserializer: D) -> Result<Minutes, D::Error>
  where
    D: Deserializer<'de>,
  {
    let seconds = u32::deserialize(deserializer)?;

    Ok(Minutes(seconds / 60))
  }
}