hackatime 1.0.0

Terminal CLI for viewing Hackatime stats with OAuth login
use chrono::NaiveDate;
use serde::Deserialize;
use serde_json::Value;

#[derive(Debug, Clone, Deserialize)]
pub struct UserProfile {
    pub id: Option<u64>,
    pub github_username: Option<String>,
    pub emails: Option<Vec<String>>,
}

impl UserProfile {
    pub fn display_name(&self) -> String {
        self.github_username
            .clone()
            .or_else(|| {
                self.emails
                    .as_ref()
                    .and_then(|emails| emails.first().cloned())
                    .and_then(|email| email.split('@').next().map(ToOwned::to_owned))
            })
            .unwrap_or_else(|| "hackatime".to_string())
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct DurationResponse {
    pub total_seconds: Option<f64>,
    pub text: Option<String>,
    pub human_readable_total: Option<String>,
    #[serde(flatten)]
    pub extra: Value,
}

impl DurationResponse {
    pub fn display(&self) -> String {
        self.text
            .clone()
            .or_else(|| self.human_readable_total.clone())
            .or_else(|| {
                self.total_seconds.map(|seconds| {
                    let hours = seconds / 3600.0;
                    format!("{hours:.1} hrs")
                })
            })
            .or_else(|| {
                self.extra
                    .get("total")
                    .and_then(Value::as_str)
                    .map(ToOwned::to_owned)
            })
            .unwrap_or_else(|| "Unavailable".to_string())
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct Heartbeat {
    pub project: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct StreakResponse {
    pub streak_days: Option<u64>,
}

impl StreakResponse {
    pub fn display(&self) -> String {
        self.streak_days
            .map(|days| format!("{days} days"))
            .unwrap_or_else(|| "Unavailable".to_string())
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProjectSummary {
    pub name: Option<String>,
    pub text: Option<String>,
    pub total_seconds: Option<f64>,
    pub last_heartbeat: Option<String>,
}

impl ProjectSummary {
    pub fn display_time(&self) -> String {
        self.text
            .clone()
            .or_else(|| {
                self.total_seconds.map(|seconds| {
                    let hours = seconds / 3600.0;
                    format!("{hours:.1} hrs")
                })
            })
            .unwrap_or_else(|| "Unavailable".to_string())
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProjectsResponse {
    pub projects: Vec<ProjectSummary>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct HeartbeatSpansResponse {
    pub spans: Vec<HeartbeatSpan>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct HeartbeatSpan {
    #[serde(alias = "start")]
    pub start_time: Option<f64>,
    #[serde(alias = "end")]
    pub end_time: Option<f64>,
    pub duration: Option<f64>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct UserStatsResponse {
    pub data: UserStatsSummary,
}

#[derive(Debug, Clone, Deserialize)]
pub struct UserStatsSummary {
    pub total_seconds: Option<f64>,
    pub human_readable_total: Option<String>,
    pub streak: Option<u64>,
    pub languages: Option<Vec<LanguageStat>>,
    pub projects: Option<Vec<ProjectStat>>,
}

impl UserStatsSummary {
    pub fn display_total(&self) -> String {
        self.human_readable_total
            .clone()
            .or_else(|| {
                self.total_seconds.map(|seconds| {
                    let hours = seconds / 3600.0;
                    format!("{hours:.1} hrs")
                })
            })
            .unwrap_or_else(|| "Unavailable".to_string())
    }

    pub fn display_streak(&self) -> String {
        self.streak
            .map(|days| format!("{days} days"))
            .unwrap_or_else(|| "Unavailable".to_string())
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct LanguageStat {
    pub name: Option<String>,
    pub total_seconds: Option<f64>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProjectStat {
    pub name: Option<String>,
    pub total_seconds: Option<f64>,
}

#[derive(Debug, Clone)]
pub struct DashboardData {
    pub title: String,
    pub layout: DashboardLayout,
    pub stats: Vec<StatLine>,
    pub languages_title: Option<String>,
    pub languages: Vec<LanguageLine>,
    pub project_graphs_title: Option<String>,
    pub project_graphs: Vec<ProjectGraphLine>,
    pub activity_graph: Option<ActivityGraph>,
}

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum DashboardLayout {
    Standard,
    Fetch,
    Projects,
    Graph,
}

#[derive(Debug, Clone)]
pub struct StatLine {
    pub label: String,
    pub value: String,
}

#[derive(Debug, Clone)]
pub struct LanguageLine {
    pub name: String,
    pub percent: f64,
    pub hours_text: String,
}

#[derive(Debug, Clone)]
pub struct ProjectGraphLine {
    pub name: String,
    pub total_seconds: f64,
    pub hours_text: String,
    pub languages: Vec<ProjectLanguageSegment>,
}

#[derive(Debug, Clone)]
pub struct ProjectLanguageSegment {
    pub name: String,
    pub percent: f64,
}

#[derive(Debug, Clone)]
pub struct ActivityGraph {
    pub weeks: Vec<ActivityWeek>,
    pub today: NaiveDate,
    pub display_end: NaiveDate,
    pub total_hours_text: String,
    pub active_days: usize,
    pub best_day_text: String,
}

#[derive(Debug, Clone)]
pub struct ActivityWeek {
    pub days: Vec<ActivityDay>,
}

#[derive(Debug, Clone)]
pub struct ActivityDay {
    pub date: NaiveDate,
    pub seconds: f64,
}

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum FetchTheme {
    Red,
    Blue,
    Green,
    Yellow,
    Pink,
    Cyan,
    Noir,
}

impl FetchTheme {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Red => "red",
            Self::Blue => "blue",
            Self::Green => "green",
            Self::Yellow => "yellow",
            Self::Pink => "pink",
            Self::Cyan => "cyan",
            Self::Noir => "noir",
        }
    }

    pub fn parse(value: &str) -> Option<Self> {
        match value {
            "red" => Some(Self::Red),
            "blue" => Some(Self::Blue),
            "green" => Some(Self::Green),
            "yellow" => Some(Self::Yellow),
            "pink" => Some(Self::Pink),
            "cyan" => Some(Self::Cyan),
            "noir" => Some(Self::Noir),
            _ => None,
        }
    }
}