2bb80a8475
Features added this session: - Target comparison: side-by-side overlay (CompareModal) from Targets page via ⊕ button on each row; shows altitude curves, key times, filter recommendations and per-filter integration progress for two targets simultaneously - Integration goal progress dashboard card: per-target keeper minutes vs goal hours (from CLAUDE.md §16.3) broken down by filter, with color-coded progress bars; powered by new stats.integration_goals backend query - Session planning timeline: Gantt-style "Plan Tonight" section on Dashboard (PlanningTimeline component) — search targets, set durations, sequential scheduling from dusk, overrun warnings, clipboard export - Slew-optimized run order toggle (nearest-neighbor sort by RA/Dec angular distance) - Best Nights 14-day card + Monthly Highlights card on Dashboard Catalog expansions: - Sharpless (Sh2), VdB, LDN, Barnard dark nebulae, LBN, Melotte, Collinder, Gum, RCW, Abell PN, Abell GC, PGC bright subset - Caldwell/Arp/Melotte/Collinder number columns + cross-reference maps - Weather score multiplier applied to composite sort - galaxy_cluster type (ACO badge) throughout TypeBadge, CSS, filter chips Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
13 KiB
Rust
318 lines
13 KiB
Rust
use axum::{extract::State, Json};
|
|
|
|
use super::{AppError, AppState};
|
|
|
|
pub async fn get_stats(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<serde_json::Value>, 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<i64> =
|
|
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<serde_json::Value> = filter_usage.iter().map(|r| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
|
"count": r.try_get::<i64, _>("count").unwrap_or_default(),
|
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = monthly.iter().map(|r| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"month": r.try_get::<String, _>("month").unwrap_or_default(),
|
|
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = type_breakdown.iter().map(|r| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
|
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = quality.iter().map(|r| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
|
"count": r.try_get::<i64, _>("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<serde_json::Value> = top_targets.iter().map(|r| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
|
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = guiding.iter().map(|r| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
|
"rms_total": r.try_get::<Option<f64>, _>("rms_total").unwrap_or_default(),
|
|
"rms_ra": r.try_get::<Option<f64>, _>("rms_ra").unwrap_or_default(),
|
|
"rms_dec": r.try_get::<Option<f64>, _>("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<serde_json::Value> = 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::<String, _>("catalog_id").unwrap_or_default(),
|
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
|
"obj_type": r.try_get::<String, _>("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<serde_json::Value> = 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<serde_json::Value> = history_rows.iter().map(|r| {
|
|
use sqlx::Row;
|
|
let catalog_id: String = r.try_get("catalog_id").unwrap_or_default();
|
|
let gallery_filename: Option<String> = 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::<String, _>("session_date").unwrap_or_default(),
|
|
"catalog_id": catalog_id,
|
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
|
"obj_type": r.try_get::<Option<String>, _>("obj_type").unwrap_or_default(),
|
|
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
|
"integration_min": r.try_get::<i64, _>("integration_min").unwrap_or(0),
|
|
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
|
"notes": r.try_get::<Option<String>, _>("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<serde_json::Value> = goals_raw.iter().map(|r| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
|
"sv220_min": r.try_get::<i64, _>("sv220_min").unwrap_or(0),
|
|
"c2_min": r.try_get::<i64, _>("c2_min").unwrap_or(0),
|
|
"uvir_min": r.try_get::<i64, _>("uvir_min").unwrap_or(0),
|
|
"sv260_min": r.try_get::<i64, _>("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,
|
|
})))
|
|
}
|