use axum::{extract::State, Json}; use super::{AppError, AppState}; pub async fn get_stats( State(state): State, ) -> Result, AppError> { // Total sessions let total_sessions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM imaging_log") .fetch_one(&state.pool) .await?; // Total integration time let total_integration_min: Option = sqlx::query_scalar("SELECT SUM(integration_min) FROM imaging_log") .fetch_optional(&state.pool) .await? .flatten(); // Objects imaged (at least one keeper) let objects_with_keeper: i64 = sqlx::query_scalar( "SELECT COUNT(DISTINCT catalog_id) FROM imaging_log WHERE quality = 'keeper'", ) .fetch_one(&state.pool) .await?; // Filter usage let filter_usage = sqlx::query( "SELECT filter_id, COUNT(*) as count, SUM(integration_min) as total_min FROM imaging_log GROUP BY filter_id", ) .fetch_all(&state.pool) .await?; let filter_stats: Vec = filter_usage.iter().map(|r| { use sqlx::Row; serde_json::json!({ "filter_id": r.try_get::("filter_id").unwrap_or_default(), "count": r.try_get::("count").unwrap_or_default(), "total_min": r.try_get::, _>("total_min").unwrap_or_default(), }) }).collect(); // Integration per month (last 12 months) let monthly = sqlx::query( r#"SELECT substr(session_date, 1, 7) as month, COUNT(*) as sessions, SUM(integration_min) as total_min FROM imaging_log WHERE session_date >= date('now', '-12 months') GROUP BY month ORDER BY month"#, ) .fetch_all(&state.pool) .await?; let monthly_stats: Vec = monthly.iter().map(|r| { use sqlx::Row; serde_json::json!({ "month": r.try_get::("month").unwrap_or_default(), "sessions": r.try_get::("sessions").unwrap_or_default(), "total_min": r.try_get::, _>("total_min").unwrap_or_default(), }) }).collect(); // Object type breakdown let type_breakdown = sqlx::query( r#"SELECT c.obj_type, COUNT(*) as sessions, SUM(l.integration_min) as total_min FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id GROUP BY c.obj_type ORDER BY total_min DESC"#, ) .fetch_all(&state.pool) .await?; let type_stats: Vec = type_breakdown.iter().map(|r| { use sqlx::Row; serde_json::json!({ "obj_type": r.try_get::("obj_type").unwrap_or_default(), "sessions": r.try_get::("sessions").unwrap_or_default(), "total_min": r.try_get::, _>("total_min").unwrap_or_default(), }) }).collect(); // Quality breakdown let quality = sqlx::query( "SELECT quality, COUNT(*) as count FROM imaging_log GROUP BY quality", ) .fetch_all(&state.pool) .await?; let quality_stats: Vec = quality.iter().map(|r| { use sqlx::Row; serde_json::json!({ "quality": r.try_get::("quality").unwrap_or_default(), "count": r.try_get::("count").unwrap_or_default(), }) }).collect(); // Top targets by integration let top_targets = sqlx::query( r#"SELECT c.id, c.name, c.common_name, c.obj_type, COUNT(l.id) as sessions, SUM(l.integration_min) as total_min FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id GROUP BY l.catalog_id ORDER BY total_min DESC LIMIT 20"#, ) .fetch_all(&state.pool) .await?; let top_target_list: Vec = top_targets.iter().map(|r| { use sqlx::Row; serde_json::json!({ "id": r.try_get::("id").unwrap_or_default(), "name": r.try_get::("name").unwrap_or_default(), "common_name": r.try_get::, _>("common_name").unwrap_or_default(), "obj_type": r.try_get::("obj_type").unwrap_or_default(), "sessions": r.try_get::("sessions").unwrap_or_default(), "total_min": r.try_get::, _>("total_min").unwrap_or_default(), }) }).collect(); // Guiding RMS over time let guiding = sqlx::query( "SELECT session_date, rms_total, rms_ra, rms_dec FROM phd2_logs ORDER BY session_date", ) .fetch_all(&state.pool) .await?; let guiding_data: Vec = guiding.iter().map(|r| { use sqlx::Row; serde_json::json!({ "date": r.try_get::("session_date").unwrap_or_default(), "rms_total": r.try_get::, _>("rms_total").unwrap_or_default(), "rms_ra": r.try_get::, _>("rms_ra").unwrap_or_default(), "rms_dec": r.try_get::, _>("rms_dec").unwrap_or_default(), }) }).collect(); // Integration gap detector: targets with one narrowband filter but missing the companion. // Pairs: sv220 ↔ c2 (for full SHO palette), uvir ↔ sv260 (broadband pair). let gaps_raw = sqlx::query( r#"SELECT l.catalog_id, c.name, c.common_name, c.obj_type, SUM(CASE WHEN l.filter_id = 'sv220' THEN l.integration_min ELSE 0 END) as sv220_min, SUM(CASE WHEN l.filter_id = 'c2' THEN l.integration_min ELSE 0 END) as c2_min, SUM(CASE WHEN l.filter_id = 'uvir' THEN l.integration_min ELSE 0 END) as uvir_min, SUM(CASE WHEN l.filter_id = 'sv260' THEN l.integration_min ELSE 0 END) as sv260_min FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id WHERE l.quality IN ('keeper', 'needs_more') GROUP BY l.catalog_id HAVING (sv220_min > 0 AND c2_min = 0) OR (c2_min > 0 AND sv220_min = 0) OR (uvir_min > 0 AND sv260_min = 0) OR (sv260_min > 0 AND uvir_min = 0) ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC LIMIT 10"#, ) .fetch_all(&state.pool) .await?; let gaps: Vec = gaps_raw.iter().map(|r| { use sqlx::Row; let sv220_min: i64 = r.try_get("sv220_min").unwrap_or(0); let c2_min: i64 = r.try_get("c2_min").unwrap_or(0); let uvir_min: i64 = r.try_get("uvir_min").unwrap_or(0); let sv260_min: i64 = r.try_get("sv260_min").unwrap_or(0); let mut missing: Vec<&str> = Vec::new(); if sv220_min > 0 && c2_min == 0 { missing.push("c2"); } if c2_min > 0 && sv220_min == 0 { missing.push("sv220"); } if uvir_min > 0 && sv260_min == 0 { missing.push("sv260"); } if sv260_min > 0 && uvir_min == 0 { missing.push("uvir"); } serde_json::json!({ "catalog_id": r.try_get::("catalog_id").unwrap_or_default(), "name": r.try_get::("name").unwrap_or_default(), "common_name": r.try_get::, _>("common_name").unwrap_or_default(), "obj_type": r.try_get::("obj_type").unwrap_or_default(), "sv220_min": sv220_min, "c2_min": c2_min, "uvir_min": uvir_min, "sv260_min": sv260_min, "missing_filters": missing, }) }).collect(); // Catalogue completion: per-catalogue keeper counts vs total observable struct CatEntry { name: &'static str, sql_filter: &'static str } let catalogues: &[CatEntry] = &[ CatEntry { name: "Messier", sql_filter: "c.messier_num IS NOT NULL" }, CatEntry { name: "Caldwell", sql_filter: "c.caldwell_num IS NOT NULL" }, CatEntry { name: "Sharpless", sql_filter: "c.id LIKE 'Sh2-%'" }, CatEntry { name: "LDN", sql_filter: "c.id LIKE 'LDN%'" }, CatEntry { name: "VdB", sql_filter: "c.id LIKE 'VdB%'" }, CatEntry { name: "NGC", sql_filter: "c.id LIKE 'NGC%'" }, CatEntry { name: "IC", sql_filter: "c.id LIKE 'IC%'" }, ]; let mut catalogue_completion: Vec = Vec::new(); for cat in catalogues { let total_sql = format!("SELECT COUNT(DISTINCT c.id) FROM catalog c WHERE {}", cat.sql_filter); let keeper_sql = format!( "SELECT COUNT(DISTINCT l.catalog_id) FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id WHERE l.quality = 'keeper' AND {}", cat.sql_filter ); let total: i64 = sqlx::query_scalar::<_, i64>(&total_sql) .fetch_one(&state.pool).await.unwrap_or(0); let keepers: i64 = sqlx::query_scalar::<_, i64>(&keeper_sql) .fetch_one(&state.pool).await.unwrap_or(0); if total > 0 { catalogue_completion.push(serde_json::json!({ "name": cat.name, "total": total, "keepers": keepers, "pct": if total > 0 { (keepers as f64 / total as f64 * 100.0).round() as i64 } else { 0 }, })); } } // Session history timeline: all sessions with first gallery image if available let history_rows = sqlx::query( r#"SELECT l.session_date, l.catalog_id, COALESCE(c.name, l.catalog_id) as name, c.common_name, c.obj_type, l.filter_id, l.integration_min, l.quality, l.notes, g.filename as gallery_filename FROM imaging_log l LEFT JOIN catalog c ON c.id = l.catalog_id LEFT JOIN ( SELECT catalog_id, MIN(filename) as filename FROM gallery GROUP BY catalog_id ) g ON g.catalog_id = l.catalog_id ORDER BY l.session_date DESC, l.created_at DESC LIMIT 500"#, ) .fetch_all(&state.pool) .await?; let history: Vec = history_rows.iter().map(|r| { use sqlx::Row; let catalog_id: String = r.try_get("catalog_id").unwrap_or_default(); let gallery_filename: Option = r.try_get("gallery_filename").unwrap_or_default(); let gallery_url = gallery_filename.map(|f| format!("/api/gallery/files/{}/{}", catalog_id, f)); serde_json::json!({ "date": r.try_get::("session_date").unwrap_or_default(), "catalog_id": catalog_id, "name": r.try_get::("name").unwrap_or_default(), "common_name": r.try_get::, _>("common_name").unwrap_or_default(), "obj_type": r.try_get::, _>("obj_type").unwrap_or_default(), "filter_id": r.try_get::("filter_id").unwrap_or_default(), "integration_min": r.try_get::("integration_min").unwrap_or(0), "quality": r.try_get::("quality").unwrap_or_default(), "notes": r.try_get::, _>("notes").unwrap_or_default(), "gallery_url": gallery_url, }) }).collect(); // Integration goals: keeper minutes per filter per target, for goal progress tracking let goals_raw = sqlx::query( r#"SELECT c.id, c.name, c.common_name, c.obj_type, SUM(CASE WHEN l.filter_id = 'sv220' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv220_min, SUM(CASE WHEN l.filter_id = 'c2' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as c2_min, SUM(CASE WHEN l.filter_id = 'uvir' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as uvir_min, SUM(CASE WHEN l.filter_id = 'sv260' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv260_min FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id WHERE l.quality = 'keeper' GROUP BY l.catalog_id ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC LIMIT 30"#, ) .fetch_all(&state.pool) .await?; let integration_goals: Vec = goals_raw.iter().map(|r| { use sqlx::Row; serde_json::json!({ "id": r.try_get::("id").unwrap_or_default(), "name": r.try_get::("name").unwrap_or_default(), "common_name": r.try_get::, _>("common_name").unwrap_or_default(), "obj_type": r.try_get::("obj_type").unwrap_or_default(), "sv220_min": r.try_get::("sv220_min").unwrap_or(0), "c2_min": r.try_get::("c2_min").unwrap_or(0), "uvir_min": r.try_get::("uvir_min").unwrap_or(0), "sv260_min": r.try_get::("sv260_min").unwrap_or(0), }) }).collect(); Ok(Json(serde_json::json!({ "total_sessions": total_sessions, "total_integration_min": total_integration_min.unwrap_or(0), "objects_with_keeper": objects_with_keeper, "filter_usage": filter_stats, "monthly": monthly_stats, "by_type": type_stats, "quality": quality_stats, "top_targets": top_target_list, "guiding": guiding_data, "integration_gaps": gaps, "history": history, "catalogue_completion": catalogue_completion, "integration_goals": integration_goals, }))) }