wakapi 0.3.1

Wakatime API client
Documentation
//! Stats endpoint.
//!
//! - Ref: <https://wakatime.com/developers#stats>
//! - Last checked : 2026-05-15

use std::collections::HashMap;

use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};

use crate::{ErrorMessage, WakapiClient, WakapiError};

use super::summaries::{
    CategoryStats, DependencyStats, EditorStats, LanguageStats, MachineStats, OsStats, ProjectStats,
};

/// Request parameters for the Stats endpoint.
///
/// Ref: <https://wakatime.com/developers#stats>
pub struct StatsParams {
    url: StatsParamsUrl,
    params: StatsParamsParams,
}

struct StatsParamsUrl {
    /// optional - range Optional range can be a YYYY year, YYYY-MM month, or one of last_7_days, last_30_days, last_6_months, last_year, or all_time
    range: Option<String>,
}

#[derive(Serialize, Default)]
struct StatsParamsParams {
    /// optional - The keystroke timeout value used to calculate these stats. Defaults the the user's keystroke timeout value.
    timeout: Option<usize>,
    /// optional - The writes_only value used to calculate these stats. Defaults to the user's writes_only setting.
    writes_only: Option<bool>,
}

impl StatsParams {
    /// Optional range can be a YYYY year, YYYY-MM month, or one of last_7_days, last_30_days, last_6_months, last_year, or all_time. When range isn’t present, the user’s public profile range is used.
    pub fn from_range(range: &str) -> StatsParams {
        StatsParams {
            url: StatsParamsUrl {
                range: Some(range.to_string()),
            },
            params: StatsParamsParams {
                timeout: None,
                writes_only: None,
            },
        }
    }

    pub fn timeout(mut self, timeout: usize) -> StatsParams {
        self.params.timeout = Some(timeout);
        self
    }

    pub fn writes_only(mut self, writes_only: bool) -> StatsParams {
        self.params.writes_only = Some(writes_only);
        self
    }
}

/// Stats endpoint.
///
/// Ref: <https://wakatime.com/developers#stats>
#[derive(Deserialize, Debug)]
pub struct Stats {
    /// data
    pub data: StatsData,
}

#[derive(Deserialize, Debug)]
pub struct StatsData {
    /// total coding activity, excluding "Other" language, as seconds for the given range of time
    pub total_seconds: f64,
    /// total coding activity as seconds for the given range of time
    pub total_seconds_including_other_language: f64,
    /// total coding activity, excluding "Other" language, as human readable string
    pub human_readable_total: String,
    /// total coding activity as human readable string
    pub human_readable_total_including_other_language: String,
    /// average coding activity per day as seconds for the given range of time, excluding Other language
    pub daily_average: f64,
    /// average coding activity per day as seconds for the given range of time
    pub daily_average_including_other_language: f64,
    /// daily average as human readable string, excluding Other language
    pub human_readable_daily_average: String,
    /// daily average as human readable string
    pub human_readable_daily_average_including_other_language: String,
    /// number of lines added by GenAI
    pub ai_additions: Option<i64>,
    /// number of lines removed by GenAI
    pub ai_deletions: Option<i64>,
    /// number of lines added by old-school typing
    pub human_additions: Option<i64>,
    /// number of lines removed by old-school typing
    pub human_deletions: Option<i64>,
    /// number of lines added or removed per GenAI agent
    pub ai_agent_line_changes: Option<HashMap<String, i64>>,
    /// total number of lines added or removed by GenAI agents
    pub ai_line_changes_total: Option<i64>,
    /// estimated USD cost per GenAI agent
    pub ai_agent_costs: Option<HashMap<String, f64>>,
    /// per-agent breakdown of lines and cost
    pub ai_agent_breakdown: Option<Vec<AiAgentBreakdown>>,
    /// estimated USD cost for all GenAI agents
    pub ai_agent_total_cost: Option<f64>,
    /// number of user input tokens used by GenAI tools
    pub ai_input_tokens: Option<i64>,
    /// number of output tokens used by GenAI tools
    pub ai_output_tokens: Option<i64>,
    /// average number of characters typed to AI tools per user prompt
    pub ai_prompt_length_avg: Option<i64>,
    /// sum of characters typed to AI tools
    pub ai_prompt_length_sum: Option<i64>,
    /// number of AI prompts
    pub ai_prompt_events: Option<i64>,
    /// categories stats
    pub categories: Vec<CategoryStats>,
    /// projects stats
    pub projects: Vec<ProjectStats>,
    /// languages stats
    pub languages: Vec<LanguageStats>,
    /// editor stats
    pub editors: Vec<EditorStats>,
    /// operating system stats
    pub operating_systems: Vec<OsStats>,
    /// dependencies stats
    pub dependencies: Vec<DependencyStats>,
    /// machine stats
    pub machines: Vec<MachineStats>,
    /// best day
    pub best_day: Option<StatsBestDay>,
    /// time range of these stats
    pub range: Option<String>,
    /// time range as human readable string
    pub human_readable_range: Option<String>,
    /// number of days in this range with no coding time logged
    pub holidays: Option<usize>,
    /// number of days in this range
    pub days_including_holidays: Option<usize>,
    /// number of days in this range excluding days with no coding time logged
    pub days_minus_holidays: Option<usize>,
    /// status of these stats in the cache
    pub status: Option<String>,
    /// percent these stats have finished updating in the background
    pub percent_calculated: Option<usize>,
    /// true if these stats are being updated in the background
    pub is_already_updating: bool,
    /// true if this response came from cached data; field absent when false
    pub is_cached: Option<bool>,
    /// true if this user's coding activity is publicly visible
    pub is_coding_activity_visible: bool,
    /// true if this user's language stats are publicly visible
    pub is_language_usage_visible: bool,
    /// true if this user's editor stats are publicly visible
    pub is_editor_usage_visible: bool,
    /// true if this user's category stats are publicly visible
    pub is_category_usage_visible: bool,
    /// true if this user's operating system stats are publicly visible
    pub is_os_usage_visible: bool,
    /// true if these stats got stuck while processing and will be recalculated in the background
    pub is_stuck: bool,
    /// true if these stats include the current day; normally false except range "all_time"
    pub is_including_today: bool,
    /// true if these stats are up to date
    pub is_up_to_date: bool,
    /// true if an update is pending for a future time range (undocumented field)
    pub is_up_to_date_pending_future: Option<bool>,
    /// start of this time range as ISO 8601 UTC datetime
    pub start: DateTime<Utc>,
    /// end of this time range as ISO 8601 UTC datetime
    pub end: DateTime<Utc>,
    /// timezone used in Olson Country/Region format
    pub timezone: String,
    /// value of the user's keystroke timeout setting in minutes
    pub timeout: usize,
    /// status of the user's writes_only setting
    pub writes_only: bool,
    /// unique id of this user
    pub user_id: String,
    /// public username for this user
    pub username: String,
    /// time when these stats were created in ISO 8601 format
    pub created_at: DateTime<Utc>,
    /// time when these stats were last updated in ISO 8601 format
    pub modified_at: DateTime<Utc>,
}

/// Per-agent breakdown of GenAI lines changed and estimated cost.
#[derive(Deserialize, Debug)]
pub struct AiAgentBreakdown {
    /// GenAI agent name
    pub name: String,
    /// number of lines added or removed by this agent
    pub lines: i64,
    /// estimated USD cost for this agent
    pub cost: f64,
}

#[derive(Deserialize, Debug)]
pub struct StatsBestDay {
    ///  day with most coding time logged as Date string in YEAR-MONTH-DAY format
    pub date: NaiveDate,
    /// total coding activity for this day in human readable format
    pub text: String,
    /// number of seconds of coding activity, including other language, for this day
    pub total_seconds: f64,
}

impl Stats {
    #[cfg(feature = "blocking")]
    /// Fetch the stats for the current user.
    pub fn fetch(client: &WakapiClient, params: StatsParams) -> Result<Self, WakapiError> {
        let url = if let Some(range) = &params.url.range {
            client.build_url(
                format!("/api/v1/users/:user/stats/{}", range).as_str(),
                Some(serde_url_params::to_string(&params.params)?),
            )
        } else {
            client.build_url(
                "/api/v1/users/:user/stats",
                Some(serde_url_params::to_string(&params.params)?),
            )
        };
        // Debug : print url and body text
        println!(
            "url: {}\nbody: {}",
            url,
            reqwest::blocking::Client::new()
                .get(&url)
                .header("Authorization", client.get_auth_header())
                .send()?
                .text()?
        );
        let response = reqwest::blocking::Client::new()
            .get(&url)
            .header("Authorization", client.get_auth_header())
            .send()?;

        if response.status().is_success() {
            let stats: Stats = response.json()?;
            Ok(stats)
        } else {
            let error_message: ErrorMessage = response.json()?;
            Err(WakapiError::ResponseError(error_message))
        }
    }

    #[cfg(not(feature = "blocking"))]
    /// Fetch the stats for the current user.
    pub async fn fetch(client: &WakapiClient, params: StatsParams) -> Result<Self, WakapiError> {
        let url = if let Some(range) = &params.url.range {
            client.build_url(
                format!("/api/v1/users/:user/stats/{}", range).as_str(),
                Some(serde_url_params::to_string(&params.params)?),
            )
        } else {
            client.build_url(
                "/api/v1/users/:user/stats",
                Some(serde_url_params::to_string(&params.params)?),
            )
        };
        // Debug : print url and body text
        println!(
            "url: {}\nbody: {}",
            url,
            reqwest::Client::new()
                .get(&url)
                .header("Authorization", client.get_auth_header())
                .send()
                .await?
                .text()
                .await?
        );
        let response = reqwest::Client::new()
            .get(&url)
            .header("Authorization", client.get_auth_header())
            .send()
            .await?;

        if response.status().is_success() {
            let stats: Stats = response.json().await?;
            Ok(stats)
        } else {
            let error_message: ErrorMessage = response.json().await?;
            Err(WakapiError::ResponseError(error_message))
        }
    }
}