use std::collections::HashMap; use chrono::{Duration, NaiveDate, Utc}; use db::sqlx; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::{IntoParams, ToSchema}; use crate::{AppService, error::AppError}; const HEATMAP_CACHE_PREFIX: &str = "user:heatmap"; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContributionHeatmapItem { pub date: String, pub count: i32, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContributionHeatmapResponse { pub username: String, pub total_contributions: i64, pub heatmap: Vec, pub start_date: String, pub end_date: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] pub struct ContributionHeatmapQuery { pub start_date: Option, pub end_date: Option, } impl AppService { pub async fn user_chpc( &self, ctx: &Session, query: ContributionHeatmapQuery, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let user = self.auth_find_user_by_uid(user_uid).await?; self.user_contribution_heatmap_for_user(user.id, user.username, query) .await } pub async fn user_contribution_heatmap_for_user( &self, user_uid: uuid::Uuid, username: String, query: ContributionHeatmapQuery, ) -> Result { let (start_date, end_date) = parse_date_range(query.start_date, query.end_date)?; let cache_key = build_heatmap_cache_key(user_uid, start_date, end_date); if let Ok(Some(cached)) = self .cache .get::(&cache_key) .await { return Ok(cached); } let start_dt = start_date .and_hms_opt(0, 0, 0) .ok_or(AppError::InternalError)? .and_utc(); let end_dt = end_date .and_hms_opt(23, 59, 59) .ok_or(AppError::InternalError)? .and_utc(); let rows = sqlx::query_as::<_, (chrono::DateTime,)>( "SELECT created_at FROM repo_commit \ WHERE (author = $1 OR committer = $1) AND created_at >= $2 AND created_at <= $3", ) .bind(user_uid) .bind(start_dt) .bind(end_dt) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let mut sparse = HashMap::::new(); for (created_at,) in &rows { let date = created_at.format("%Y-%m-%d").to_string(); *sparse.entry(date).or_insert(0) += 1; } let mut heatmap = Vec::new(); let mut current = start_date; while current <= end_date { let date = current.format("%Y-%m-%d").to_string(); let count = sparse.get(&date).copied().unwrap_or(0) as i32; heatmap.push(ContributionHeatmapItem { date, count }); current += Duration::days(1); } let response = ContributionHeatmapResponse { username, total_contributions: rows.len() as i64, heatmap, start_date: start_date.format("%Y-%m-%d").to_string(), end_date: end_date.format("%Y-%m-%d").to_string(), }; self.cache .set(&cache_key, &response) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; Ok(response) } pub async fn user_invalidate_chpc_cache( &self, ctx: &Session, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; self.invalidate_user_heatmap_cache(user_uid).await } pub async fn invalidate_user_heatmap_cache( &self, user_uid: uuid::Uuid, ) -> Result<(), AppError> { let pattern = format!("{}:{}:*", HEATMAP_CACHE_PREFIX, user_uid); self.cache .delete_pattern(&pattern) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; Ok(()) } } fn build_heatmap_cache_key( user_uid: uuid::Uuid, start_date: NaiveDate, end_date: NaiveDate, ) -> String { format!( "{}:{}:{}:{}", HEATMAP_CACHE_PREFIX, user_uid, start_date.format("%Y-%m-%d"), end_date.format("%Y-%m-%d"), ) } fn parse_date_range( start_date_str: Option, end_date_str: Option, ) -> Result<(NaiveDate, NaiveDate), AppError> { let today = Utc::now().date_naive(); let one_year_ago = today - Duration::days(365); let start_date = match start_date_str { Some(date) => parse_date(&date, "start_date")?, None => one_year_ago, }; let end_date = match end_date_str { Some(date) => parse_date(&date, "end_date")?, None => today, }; if start_date > end_date { return Err(AppError::BadRequest( "start_date cannot be later than end_date".to_string(), )); } if (end_date - start_date).num_days() > 730 { return Err(AppError::BadRequest( "date range cannot exceed 2 years".to_string(), )); } Ok((start_date, end_date)) } fn parse_date(value: &str, field: &str) -> Result { NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| { AppError::BadRequest(format!( "invalid {field} format, expected YYYY-MM-DD" )) }) }