use axum::extract::State; use axum::Json; use chrono::Utc; use crate::auth::AuthUser; use crate::config::AppState; use crate::errors::AppError; use crate::models::{ LessonProgress, Progress, ProgressResponse, ProgressUpdateRequest, StatsResponse, UserStats, }; // --------------------------------------------------------------------------- // GET /api/progress // --------------------------------------------------------------------------- pub async fn get_progress( State(state): State, AuthUser(user_id): AuthUser, ) -> Result, AppError> { let rows = sqlx::query_as::<_, Progress>( "SELECT user_id, topic_id, lesson_id, completed, best_score, completed_at \ FROM progress WHERE user_id = ?", ) .bind(&user_id) .fetch_all(&state.db) .await?; let stats = sqlx::query_as::<_, UserStats>( "SELECT user_id, xp, streak_days, last_active_date, hearts, streak_freezes \ FROM user_stats WHERE user_id = ?", ) .bind(&user_id) .fetch_optional(&state.db) .await? .ok_or_else(|| AppError::NotFound("User stats not found".to_string()))?; let lessons: Vec = rows .into_iter() .map(|p| LessonProgress { topic_id: p.topic_id, lesson_id: p.lesson_id, completed: p.completed != 0, best_score: p.best_score, completed_at: p.completed_at, }) .collect(); Ok(Json(ProgressResponse { lessons, stats: StatsResponse { xp: stats.xp, streak_days: stats.streak_days, hearts: stats.hearts, streak_freezes: stats.streak_freezes, }, })) } // --------------------------------------------------------------------------- // PUT /api/progress // --------------------------------------------------------------------------- pub async fn update_progress( State(state): State, AuthUser(user_id): AuthUser, Json(body): Json, ) -> Result, AppError> { let today = Utc::now().format("%Y-%m-%d").to_string(); let now_iso = Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); // Upsert progress row. // If the row already exists, only update best_score when the new score is higher. let existing = sqlx::query_as::<_, Progress>( "SELECT user_id, topic_id, lesson_id, completed, best_score, completed_at \ FROM progress WHERE user_id = ? AND topic_id = ? AND lesson_id = ?", ) .bind(&user_id) .bind(&body.topic_id) .bind(&body.lesson_id) .fetch_optional(&state.db) .await?; match existing { Some(prev) => { let new_best = if body.score > prev.best_score { body.score } else { prev.best_score }; sqlx::query( "UPDATE progress SET best_score = ?, completed = 1, completed_at = ? \ WHERE user_id = ? AND topic_id = ? AND lesson_id = ?", ) .bind(new_best) .bind(&now_iso) .bind(&user_id) .bind(&body.topic_id) .bind(&body.lesson_id) .execute(&state.db) .await?; } None => { sqlx::query( "INSERT INTO progress (user_id, topic_id, lesson_id, completed, best_score, completed_at) \ VALUES (?, ?, ?, 1, ?, ?)", ) .bind(&user_id) .bind(&body.topic_id) .bind(&body.lesson_id) .bind(body.score) .bind(&now_iso) .execute(&state.db) .await?; } } // Fetch current stats for streak calculation. let stats = sqlx::query_as::<_, UserStats>( "SELECT user_id, xp, streak_days, last_active_date, hearts, streak_freezes \ FROM user_stats WHERE user_id = ?", ) .bind(&user_id) .fetch_one(&state.db) .await?; // Streak logic with streak freeze support. let yesterday = (Utc::now() - chrono::Duration::days(1)) .format("%Y-%m-%d") .to_string(); let (new_streak, new_freezes) = match stats.last_active_date.as_deref() { Some(d) if d == today => { // Already active today — no streak change (stats.streak_days, stats.streak_freezes) } Some(d) if d == yesterday => { // Extend streak by 1 let extended = stats.streak_days + 1; // Award a streak freeze every 7 days let freezes = if extended > 0 && extended % 7 == 0 { stats.streak_freezes + 1 } else { stats.streak_freezes }; (extended, freezes) } Some(last_date) => { // Gap in activity — check if streak freezes can cover the missed days let missed_days = chrono::NaiveDate::parse_from_str(&today, "%Y-%m-%d") .ok() .and_then(|t| { chrono::NaiveDate::parse_from_str(last_date, "%Y-%m-%d") .ok() .map(|l| (t - l).num_days() - 1) // days between last active and today, exclusive }) .unwrap_or(i64::MAX); if missed_days > 0 && missed_days <= stats.streak_freezes as i64 { // Consume freezes for missed days, extend streak for today's activity let extended = stats.streak_days + 1; let freezes = stats.streak_freezes - missed_days as i32; // Award a streak freeze every 7 days let freezes = if extended > 0 && extended % 7 == 0 { freezes + 1 } else { freezes }; (extended, freezes) } else { // Not enough freezes — reset streak (1, 0) } } None => { // First time ever (1, 0) } }; let new_xp = stats.xp + body.xp_earned; sqlx::query( "UPDATE user_stats SET xp = ?, streak_days = ?, streak_freezes = ?, last_active_date = ? WHERE user_id = ?", ) .bind(new_xp) .bind(new_streak) .bind(new_freezes) .bind(&today) .bind(&user_id) .execute(&state.db) .await?; Ok(Json(StatsResponse { xp: new_xp, streak_days: new_streak, hearts: stats.hearts, streak_freezes: new_freezes, })) }