use axum::{ extract::{Path, Query, State}, Json, }; use chrono::NaiveDate; use serde::Deserialize; use crate::astronomy::{julian_date, moon_illumination}; use super::{AppError, AppState}; /// Returns new moon windows (dates where moon < 5%) with top 3 emission nebulae each. pub async fn get_new_moon_windows( State(state): State, ) -> Result, AppError> { use sqlx::Row; // Get all new moon dates in the next 365 days let today = chrono::Utc::now().naive_utc().date(); let end = today + chrono::Duration::days(365); let mut windows: Vec = Vec::new(); let mut cur = today; let mut prev_illum = moon_illum_for_date(cur); while cur <= end { let illum = moon_illum_for_date(cur); let next_illum = moon_illum_for_date(cur + chrono::Duration::days(1)); // New moon = local minimum < 5% if illum < 0.05 && illum <= prev_illum && illum <= next_illum { let date_str = cur.to_string(); // Top 3 emission nebulae for this night from nightly_cache let targets = sqlx::query( r#"SELECT c.id, c.name, c.common_name, nc.max_alt_deg, nc.recommended_filter FROM nightly_cache nc JOIN catalog c ON c.id = nc.catalog_id WHERE nc.night_date = ? AND c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula') AND nc.max_alt_deg >= 20 ORDER BY nc.max_alt_deg DESC LIMIT 3"#, ) .bind(&date_str) .fetch_all(&state.pool) .await?; let top_targets: Vec = targets.iter().map(|r| 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(), "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), "recommended_filter": r.try_get::, _>("recommended_filter").unwrap_or_default(), })).collect(); windows.push(serde_json::json!({ "date": date_str, "illumination": illum, "top_targets": top_targets, })); } prev_illum = illum; cur += chrono::Duration::days(1); } Ok(Json(serde_json::json!({ "windows": windows }))) } #[derive(Debug, Deserialize)] pub struct CalendarQuery { pub months: Option, } /// Compute moon illumination for a given calendar date (at 21:00 UTC = start of night). fn moon_illum_for_date(date: NaiveDate) -> f64 { let dt = date.and_hms_opt(21, 0, 0) .map(|dt| chrono::DateTime::from_naive_utc_and_offset(dt, chrono::Utc)) .unwrap_or_else(chrono::Utc::now); let jd = julian_date(dt); moon_illumination(jd) } pub async fn get_calendar( State(state): State, Query(params): Query, ) -> Result, AppError> { let months = params.months.unwrap_or(3).min(12) as i64; let today = chrono::Utc::now().naive_utc().date(); let end = today + chrono::Duration::days(months * 30); // Pull nightly cache data for the date range let rows = sqlx::query( r#"SELECT nc.night_date, COUNT(CASE WHEN nc.max_alt_deg >= 15 THEN 1 END) as visible_count, MAX(nc.usable_min) as max_usable_min, AVG(nc.max_alt_deg) as avg_max_alt FROM nightly_cache nc WHERE nc.night_date >= ? AND nc.night_date <= ? GROUP BY nc.night_date ORDER BY nc.night_date"#, ) .bind(today.to_string()) .bind(end.to_string()) .fetch_all(&state.pool) .await?; // Build a map from date string → nightly cache data let cache_map: std::collections::HashMap = rows.iter().map(|r| { use sqlx::Row; let date = r.try_get::, _>("night_date").unwrap_or_default().unwrap_or_default(); let visible = r.try_get::, _>("visible_count").unwrap_or_default().unwrap_or(0); let usable = r.try_get::, _>("max_usable_min").unwrap_or_default().unwrap_or(0); let avg_alt = r.try_get::, _>("avg_max_alt").unwrap_or_default().unwrap_or(0.0); (date, (visible, usable, avg_alt)) }).collect(); // Generate a day entry for every calendar day in range (so moon is always shown) let mut days = Vec::new(); let mut cur = today; while cur <= end { let date_str = cur.to_string(); let moon_illum = moon_illum_for_date(cur); let (visible_count, max_usable_min, avg_max_alt) = cache_map.get(&date_str) .copied() .unwrap_or((0, 0, 0.0)); days.push(serde_json::json!({ "date": date_str, "visible_count": visible_count, "max_usable_min": max_usable_min, "avg_max_alt": avg_max_alt, "moon_illumination": moon_illum, })); cur += chrono::Duration::days(1); } Ok(Json(serde_json::json!({ "days": days }))) } pub async fn get_calendar_date( State(state): State, Path(date): Path, ) -> Result, AppError> { use sqlx::Row; // Top 10 targets for this night let targets = sqlx::query( r#"SELECT c.id, c.name, c.common_name, c.obj_type, nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter FROM nightly_cache nc JOIN catalog c ON c.id = nc.catalog_id WHERE nc.night_date = ? AND nc.max_alt_deg >= 15 ORDER BY nc.max_alt_deg DESC LIMIT 10"#, ) .bind(&date) .fetch_all(&state.pool) .await?; let target_list: Vec = targets.iter().map(|r| { 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(), "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), "usable_min": r.try_get::, _>("usable_min").unwrap_or_default(), "transit_utc": r.try_get::, _>("transit_utc").unwrap_or_default(), "recommended_filter": r.try_get::, _>("recommended_filter").unwrap_or_default(), }) }).collect(); // Tonight summary from the `tonight` table (only available for tonight's date) let tonight_row = sqlx::query("SELECT * FROM tonight WHERE id = 1 AND date = ?") .bind(&date) .fetch_optional(&state.pool) .await?; let tonight_summary = tonight_row.map(|r| serde_json::json!({ "astro_dusk_utc": r.try_get::, _>("astro_dusk_utc").unwrap_or_default(), "astro_dawn_utc": r.try_get::, _>("astro_dawn_utc").unwrap_or_default(), "moon_rise_utc": r.try_get::, _>("moon_rise_utc").unwrap_or_default(), "moon_set_utc": r.try_get::, _>("moon_set_utc").unwrap_or_default(), "moon_illumination": r.try_get::, _>("moon_illumination").unwrap_or_default(), "moon_phase_name": r.try_get::, _>("moon_phase_name").unwrap_or_default(), "true_dark_start_utc": r.try_get::, _>("true_dark_start_utc").unwrap_or_default(), "true_dark_end_utc": r.try_get::, _>("true_dark_end_utc").unwrap_or_default(), "true_dark_minutes": r.try_get::, _>("true_dark_minutes").unwrap_or_default(), })); // Weather summary from cache (only meaningful for today/near future) let weather_row = sqlx::query( "SELECT go_nogo, temp_c, dew_point_c, seventimer_json FROM weather_cache WHERE id = 1" ) .fetch_optional(&state.pool) .await?; let weather_summary = weather_row.map(|r| { let go_nogo = r.try_get::, _>("go_nogo").unwrap_or_default(); let temp_c = r.try_get::, _>("temp_c").unwrap_or_default(); let dew_c = r.try_get::, _>("dew_point_c").unwrap_or_default(); let seventimer: Option = r.try_get::, _>("seventimer_json") .unwrap_or_default() .and_then(|s| serde_json::from_str(&s).ok()); // Extract tonight's cloudcover/seeing from 7timer if available let (cloudcover, seeing, transparency) = seventimer .as_ref() .and_then(|v| v.get("dataseries")?.as_array()?.first().cloned()) .map(|slot| ( slot.get("cloudcover").and_then(|v| v.as_i64()), slot.get("seeing").and_then(|v| v.as_i64()), slot.get("transparency").and_then(|v| v.as_i64()), )) .unwrap_or((None, None, None)); serde_json::json!({ "go_nogo": go_nogo, "temp_c": temp_c, "dew_point_c": dew_c, "cloudcover": cloudcover, "seeing": seeing, "transparency": transparency, }) }); // Moon illumination for the requested date (always computable) let requested_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d") .unwrap_or_else(|_| chrono::Utc::now().naive_utc().date()); let moon_illum = moon_illum_for_date(requested_date); Ok(Json(serde_json::json!({ "date": date, "moon_illumination": moon_illum, "top_targets": target_list, "tonight": tonight_summary, "weather": weather_summary, }))) }