hackatime-heatmap 0.3.1

Easy to set up Hackatime coding activity heatmap for your profile!
use std::collections::HashMap;

use chrono::{Datelike, Duration, NaiveDate, TimeZone};
use chrono_tz::Tz;

use crate::Span;

pub fn human_time(seconds: u32) -> String {
    let hours = seconds / 3600;
    let minutes = (seconds % 3600) / 60;
    let seconds = seconds % 60;
    if hours > 0 {
        if minutes == 0 {
            format!("{}h", hours)
        } else {
            format!("{}h {}m", hours, minutes)
        }
    } else if minutes > 0 {
        if seconds == 0 {
            format!("{}m", minutes)
        } else {
            format!("{}m {}s", minutes, seconds)
        }
    } else {
        "<1m".to_string()
    }
}

pub fn create_timezone_timestamp(
    tz: &Tz,
    date: &chrono::NaiveDate,
    hour: u32,
    minute: u32,
    second: u32,
) -> Result<f64, String> {
    tz.with_ymd_and_hms(date.year(), date.month(), date.day(), hour, minute, second)
        .single()
        .map(|dt| dt.timestamp() as f64)
        .ok_or_else(|| "Invalid date/time".to_string())
}

pub fn generate_date_range(start: NaiveDate, end: NaiveDate) -> Vec<NaiveDate> {
    let mut dates = Vec::new();
    let mut current = start;
    while current <= end {
        dates.push(current);
        current += Duration::days(1);
    }
    dates
}

pub fn process_span_into_buckets(
    span: &Span,
    tz: &Tz,
    day_buckets: &mut HashMap<chrono::NaiveDate, u32>,
) {
    let start_dt_utc =
        match chrono::DateTime::<chrono::Utc>::from_timestamp(span.start_time as i64, 0) {
            Some(dt) => dt,
            None => {
                eprintln!("Invalid start timestamp: {}", span.start_time);
                return;
            }
        };
    let end_dt_utc = match chrono::DateTime::<chrono::Utc>::from_timestamp(span.end_time as i64, 0)
    {
        Some(dt) => dt,
        None => {
            eprintln!("Invalid end timestamp: {}", span.end_time);
            return;
        }
    };

    let start_local = start_dt_utc.with_timezone(tz);
    let end_local = end_dt_utc.with_timezone(tz);
    let start_date = start_local.date_naive();
    let end_date = end_local.date_naive();

    if start_date == end_date {
        *day_buckets.entry(start_date).or_insert(0) += span.duration.round() as u32;
    } else {
        split_span_across_days(span, &start_local, &end_local, tz, day_buckets);
    }
}

fn split_span_across_days(
    span: &Span,
    start_local: &chrono::DateTime<Tz>,
    end_local: &chrono::DateTime<Tz>,
    tz: &Tz,
    day_buckets: &mut HashMap<chrono::NaiveDate, u32>,
) {
    let mut current = *start_local;
    let mut remaining = span.duration;
    let end_date = end_local.date_naive();

    while current.date_naive() < end_date {
        let next_midnight = match tz
            .with_ymd_and_hms(current.year(), current.month(), current.day(), 23, 59, 59)
            .single()
        {
            Some(dt) => dt,
            None => {
                eprintln!("Invalid next midnight date");
                break;
            }
        };

        let seconds = (next_midnight.timestamp() - current.timestamp() + 1) as f64;
        let to_add = seconds.min(remaining).round() as u32;
        *day_buckets.entry(current.date_naive()).or_insert(0) += to_add;
        remaining -= seconds;
        current = next_midnight + Duration::seconds(1);
    }

    *day_buckets.entry(end_date).or_insert(0) += remaining.round() as u32;
}