Add target comparison modal, integration goal progress, and session planning + full catalog expansion

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>
This commit is contained in:
2026-04-17 07:20:10 +02:00
parent 8f72745bc0
commit 2bb80a8475
45 changed files with 5613 additions and 628 deletions
+129
View File
@@ -140,6 +140,135 @@ pub async fn get_calendar(
Ok(Json(serde_json::json!({ "days": days })))
}
/// Returns the next 14 nights ranked by composite night score.
pub async fn get_best_nights(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
let today = chrono::Utc::now().naive_utc().date();
let mut nights: Vec<serde_json::Value> = Vec::new();
for i in 0..14i64 {
let date = today + chrono::Duration::days(i);
let date_str = date.to_string();
let moon_illum = moon_illum_for_date(date);
// Pull nightly cache stats for this night
let cache_row = sqlx::query(
r#"SELECT
COUNT(CASE WHEN nc.max_alt_deg >= 15 THEN 1 END) as visible_count,
AVG(CASE WHEN nc.max_alt_deg >= 30 THEN nc.usable_min ELSE NULL END) as avg_usable_min
FROM nightly_cache nc
WHERE nc.night_date = ?"#,
)
.bind(&date_str)
.fetch_optional(&state.pool)
.await?;
let (visible_count, avg_usable_min): (i64, f64) = cache_row.as_ref().map(|r| (
r.try_get::<Option<i64>, _>("visible_count").unwrap_or_default().unwrap_or(0),
r.try_get::<Option<f64>, _>("avg_usable_min").unwrap_or_default().unwrap_or(0.0),
)).unwrap_or((0, 0.0));
// Moon score: low illumination = better
let moon_score = 1.0 - moon_illum;
// Dark hours score: normalize to 01 assuming max useful ~480 min
let dark_score = (avg_usable_min / 480.0).min(1.0);
// Composite (no weather score for future nights without forecast)
let score = moon_score * 0.5 + dark_score * 0.5;
// Top 3 targets for this night
let top_targets_rows = sqlx::query(
r#"SELECT c.id, c.name, c.common_name, c.obj_type, nc.max_alt_deg, nc.usable_min, 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 >= 20
ORDER BY nc.max_alt_deg DESC
LIMIT 3"#,
)
.bind(&date_str)
.fetch_all(&state.pool)
.await?;
let top_targets: Vec<serde_json::Value> = top_targets_rows.iter().map(|r| 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(),
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
})).collect();
nights.push(serde_json::json!({
"date": date_str,
"score": (score * 100.0) as u32,
"moon_illumination": moon_illum,
"visible_count": visible_count,
"avg_usable_min": avg_usable_min as u32,
"top_targets": top_targets,
}));
}
// Sort by score descending
nights.sort_by(|a, b| {
let sa = a["score"].as_u64().unwrap_or(0);
let sb = b["score"].as_u64().unwrap_or(0);
sb.cmp(&sa)
});
Ok(Json(serde_json::json!({ "nights": nights })))
}
/// Returns top 5 targets that peak this calendar month, preferring not-yet-imaged.
pub async fn get_monthly_highlights(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
let today = chrono::Utc::now().naive_utc().date();
let month_prefix = today.format("%Y-%m").to_string();
let rows = sqlx::query(
r#"SELECT
c.id, c.name, c.common_name, c.obj_type, c.constellation,
MAX(nc.max_alt_deg) as peak_alt,
MAX(nc.usable_min) as best_usable,
nc.recommended_filter,
nc.transit_utc,
(SELECT COUNT(*) FROM imaging_log il
WHERE il.catalog_id = c.id AND il.quality = 'keeper') as keeper_count
FROM nightly_cache nc
JOIN catalog c ON c.id = nc.catalog_id
WHERE nc.night_date LIKE ?
AND nc.max_alt_deg >= 20
AND nc.is_visible_tonight = 1
GROUP BY c.id
ORDER BY keeper_count ASC, peak_alt DESC
LIMIT 8"#,
)
.bind(format!("{}%", month_prefix))
.fetch_all(&state.pool)
.await?;
let highlights: Vec<serde_json::Value> = rows.iter().map(|r| 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(),
"constellation": r.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"peak_alt": r.try_get::<Option<f64>, _>("peak_alt").unwrap_or_default(),
"best_usable_min": r.try_get::<Option<i32>, _>("best_usable").unwrap_or_default(),
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
"keeper_count": r.try_get::<i64, _>("keeper_count").unwrap_or_default(),
})).collect();
Ok(Json(serde_json::json!({
"month": month_prefix,
"highlights": highlights,
})))
}
pub async fn get_calendar_date(
State(state): State<AppState>,
Path(date): Path<String>,
+3 -3
View File
@@ -30,7 +30,7 @@ pub async fn list_all_gallery(
"id": id,
"catalog_id": &catalog_id,
"filename": &filename,
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
@@ -60,7 +60,7 @@ pub async fn list_gallery(
"id": id,
"catalog_id": catalog_id,
"filename": filename,
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
})
@@ -147,7 +147,7 @@ pub async fn upload_image(
"id": id,
"catalog_id": catalog_id,
"filename": filename,
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
})))
}
+5 -1
View File
@@ -66,12 +66,15 @@ pub fn build_router(pool: SqlitePool) -> Router {
.route("/api/targets/:id/filters", get(targets::get_filters))
.route("/api/targets/:id/workflow/:filter_id", get(targets::get_workflow_handler))
.route("/api/targets/:id/yearly", get(targets::get_yearly))
.route("/api/targets/:id/similar", get(targets::get_similar))
.route("/api/targets/:id/notes", get(targets::get_notes).put(targets::put_notes))
// Tonight
.route("/api/tonight", get(tonight::get_tonight))
// Calendar
.route("/api/calendar", get(calendar::get_calendar))
.route("/api/calendar/new-moon-windows", get(calendar::get_new_moon_windows))
.route("/api/calendar/best-nights", get(calendar::get_best_nights))
.route("/api/calendar/monthly-highlights", get(calendar::get_monthly_highlights))
.route("/api/calendar/:date", get(calendar::get_calendar_date))
// Weather
.route("/api/weather", get(weather::get_weather))
@@ -103,8 +106,9 @@ pub fn build_router(pool: SqlitePool) -> Router {
// Stats
.route("/api/stats", get(stats::get_stats))
// Static gallery files served via tower-http
// Must be under /api/ so nginx proxies it to the backend, not the frontend.
.nest_service(
"/data/gallery",
"/api/gallery/files",
tower_http::services::ServeDir::new(gallery_dir),
)
.with_state(state)
+2 -2
View File
@@ -73,7 +73,7 @@ pub struct SolarSystemObject {
pub is_visible: bool, // alt > 15°
}
fn fmt_ra(ra: f64) -> String {
pub fn fmt_ra(ra: f64) -> String {
let total_sec = (ra / 15.0) * 3600.0;
let h = (total_sec / 3600.0) as u32;
let m = ((total_sec % 3600.0) / 60.0) as u32;
@@ -81,7 +81,7 @@ fn fmt_ra(ra: f64) -> String {
format!("{:02}h {:02}m {:02}s", h, m, s)
}
fn fmt_dec(dec: f64) -> String {
pub fn fmt_dec(dec: f64) -> String {
let sign = if dec < 0.0 { "-" } else { "+" };
let abs = dec.abs();
let d = abs as u32;
+166
View File
@@ -137,6 +137,168 @@ pub async fn get_stats(
})
}).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),
@@ -147,5 +309,9 @@ pub async fn get_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,
})))
}
+362 -101
View File
@@ -8,12 +8,46 @@ use crate::{
astronomy::{
astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination,
moon_position, HorizonPoint, MoonState, TonightWindow,
coords::radec_to_altaz,
time::local_sidereal_time,
},
config::{LAT, LON},
filters::{get_workflow, recommend_filters},
};
use super::{AppError, AppState};
use super::solar_system::{fmt_ra, fmt_dec};
/// Look up (ra_deg, dec_deg, obj_type) from catalog first, then custom_targets.
/// Returns None if the target is not found in either table or has no coordinates.
async fn lookup_coords(pool: &sqlx::SqlitePool, id: &str) -> Result<Option<(f64, f64, String)>, AppError> {
use sqlx::Row;
if let Some(row) = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
{
return Ok(Some((
row.try_get("ra_deg").unwrap_or_default(),
row.try_get("dec_deg").unwrap_or_default(),
row.try_get("obj_type").unwrap_or_default(),
)));
}
if let Some(row) = sqlx::query(
"SELECT ra_deg, dec_deg, obj_type FROM custom_targets WHERE id = ? AND ra_deg IS NOT NULL AND dec_deg IS NOT NULL",
)
.bind(id)
.fetch_optional(pool)
.await?
{
return Ok(Some((
row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
)));
}
Ok(None)
}
#[derive(Debug, Deserialize)]
pub struct TargetsQuery {
@@ -30,6 +64,7 @@ pub struct TargetsQuery {
pub min_usable_min: Option<i32>,
pub mosaic_only: Option<bool>,
pub not_imaged: Option<bool>,
pub show_custom: Option<bool>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
@@ -81,8 +116,17 @@ pub async fn list_targets(
let mut bind_values: Vec<String> = vec![];
if let Some(ref t) = params.obj_type {
conditions.push("c.obj_type = ?".to_string());
bind_values.push(t.clone());
let types: Vec<&str> = t.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if types.len() == 1 {
conditions.push("c.obj_type = ?".to_string());
bind_values.push(types[0].to_string());
} else if !types.is_empty() {
let placeholders = types.iter().map(|_| "?").collect::<Vec<_>>().join(",");
conditions.push(format!("c.obj_type IN ({})", placeholders));
for ty in &types {
bind_values.push(ty.to_string());
}
}
}
if let Some(ref con) = params.constellation {
conditions.push("c.constellation = ?".to_string());
@@ -92,7 +136,7 @@ pub async fn list_targets(
// Filter by filter suitability: use object-type compatibility, not moon-state-dependent recommended_filter.
// This ensures these filters always return results regardless of current moon phase.
match f.as_str() {
"uvir" => conditions.push("c.obj_type IN ('galaxy', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()),
"uvir" => conditions.push("c.obj_type IN ('galaxy', 'galaxy_cluster', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()),
"c2" | "sv220" => conditions.push("c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula')".to_string()),
"sv260" => {}, // LP filter works for all object types — no restriction
_ => {
@@ -120,9 +164,9 @@ pub async fn list_targets(
// (e.g. globular clusters, dark nebulae, open clusters) still appear.
// Skip filter when search is active so you can find objects like M31 off-season.
if tonight_filter && params.search.as_deref().unwrap_or("").is_empty() {
// Allow objects with no nightly_cache entry yet (newly ingested, NULL max_alt_deg)
// so freshly added VdB/LDN objects are visible before the first nightly precompute.
conditions.push("(nc.max_alt_deg >= 15 OR nc.max_alt_deg IS NULL)".to_string());
// Use horizon-aware is_visible_tonight flag stored in nightly_cache.
// Fall back to max_alt_deg >= 15 for rows that predate the flag (NULL).
conditions.push("(nc.is_visible_tonight = 1 OR (nc.is_visible_tonight IS NULL AND nc.max_alt_deg >= 15) OR nc.catalog_id IS NULL)".to_string());
}
if let Some(ref s) = params.search {
let like = format!("%{}%", s);
@@ -130,6 +174,15 @@ pub async fn list_targets(
let m_num: Option<i32> = s.trim()
.strip_prefix(['M', 'm'])
.and_then(|n| n.parse().ok());
// Support C-number search (e.g. "C20" → caldwell_num = 20)
let c_num: Option<i32> = s.trim()
.strip_prefix(['C', 'c'])
.and_then(|n| n.parse().ok());
// Support Arp search (e.g. "Arp85" → arp_num = 85)
let arp_num: Option<i32> = s.trim()
.to_lowercase()
.strip_prefix("arp")
.and_then(|n| n.trim().parse().ok());
if let Some(m) = m_num {
conditions.push(format!(
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.messier_num = {})",
@@ -138,6 +191,21 @@ pub async fn list_targets(
bind_values.push(like.clone());
bind_values.push(like.clone());
bind_values.push(like);
} else if let Some(c) = c_num {
conditions.push(format!(
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.caldwell_num = {})",
c
));
bind_values.push(like.clone());
bind_values.push(like.clone());
bind_values.push(like);
} else if let Some(a) = arp_num {
conditions.push(format!(
"(c.name LIKE ? OR c.common_name LIKE ? OR c.arp_num = {})",
a
));
bind_values.push(like.clone());
bind_values.push(like);
} else {
conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string());
bind_values.push(like.clone());
@@ -147,10 +215,26 @@ pub async fn list_targets(
}
let where_clause = conditions.join(" AND ");
// Fetch weather weight: go=1.0, marginal=0.7, nogo=0.3 (default 1.0 if no forecast)
let weather_weight: f64 = sqlx::query_scalar::<_, Option<String>>(
"SELECT go_nogo FROM weather_cache WHERE id = 1"
)
.fetch_optional(&state.pool)
.await
.unwrap_or(None)
.flatten()
.map(|s| match s.as_str() {
"go" => 1.0,
"marginal" => 0.7,
"nogo" => 0.3,
_ => 1.0,
})
.unwrap_or(1.0);
// "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation.
// Score = alt_score * 0.4 + fill_score * 0.3 + time_score * 0.2 + moon_score * 0.1
// Targets outside 20150% FOV fill are penalised (too small or too large single-panel).
let best_score_expr = r#"(
// Multiplied by weather_weight so cloudy nights rank all targets lower.
let best_score_expr = format!(r#"(
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
+ CASE
WHEN c.fov_fill_pct IS NULL THEN 0.15
@@ -160,33 +244,47 @@ pub async fn list_targets(
END
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10
) DESC"#;
let sort_col = match params.sort.as_deref() {
Some("transit") => "nc.transit_utc",
Some("size") => "c.size_arcmin_maj DESC",
Some("magnitude") => "c.mag_v",
Some("difficulty") => "c.difficulty",
Some("integration") => "total_integration DESC",
Some("altitude") => "nc.max_alt_deg DESC",
) * {weather_weight:.2} DESC"#, weather_weight = weather_weight);
let sort_col_owned: String = match params.sort.as_deref() {
Some("transit") => "nc.transit_utc".to_string(),
Some("size") => "c.size_arcmin_maj DESC".to_string(),
Some("magnitude") => "c.mag_v".to_string(),
Some("difficulty") => "c.difficulty".to_string(),
Some("integration") => "total_integration DESC".to_string(),
Some("altitude") => "nc.max_alt_deg DESC".to_string(),
Some("best_start") => "nc.best_start_utc ASC NULLS LAST".to_string(),
_ => best_score_expr,
};
let sort_col = sort_col_owned.as_str();
// Compare tonight's altitude to the seasonal peak (max over next 90 days).
// urgency: 'peak' >= 90%, 'rising' = 7090% and date < peak date, 'declining' = 7090% and date >= peak date, else null
let sql = format!(
r#"
SELECT c.id, c.name, c.common_name, c.obj_type, c.ra_deg, c.dec_deg, c.ra_h, c.dec_dms,
c.constellation, c.size_arcmin_maj, c.size_arcmin_min, c.mag_v, c.surface_brightness,
c.hubble_type, c.messier_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag,
c.hubble_type, c.messier_num, c.caldwell_num, c.arp_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag,
c.mosaic_panels_w, c.mosaic_panels_h, c.difficulty, c.guide_star_density,
nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter,
nc.best_start_utc, nc.best_end_utc, nc.moon_sep_deg,
CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END as is_visible_tonight,
COALESCE(log_sum.total_min, 0) as total_integration
COALESCE(nc.is_visible_tonight, CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END) as is_visible_tonight,
COALESCE(log_sum.total_min, 0) as total_integration,
seas.peak_alt as seasonal_peak_alt,
seas.peak_date as seasonal_peak_date
FROM catalog c
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}'
LEFT JOIN (
SELECT catalog_id, SUM(integration_min) as total_min
FROM imaging_log GROUP BY catalog_id
) log_sum ON log_sum.catalog_id = c.id
LEFT JOIN (
SELECT catalog_id,
MAX(max_alt_deg) as peak_alt,
MIN(CASE WHEN max_alt_deg = (SELECT MAX(max_alt_deg) FROM nightly_cache n2 WHERE n2.catalog_id = n1.catalog_id AND n2.night_date BETWEEN '{today}' AND date('{today}', '+90 days')) THEN night_date ELSE NULL END) as peak_date
FROM nightly_cache n1
WHERE night_date BETWEEN '{today}' AND date('{today}', '+90 days')
GROUP BY catalog_id
) seas ON seas.catalog_id = c.id
WHERE {where_clause}
ORDER BY {sort_col}
LIMIT {limit} OFFSET {offset}
@@ -209,8 +307,24 @@ pub async fn list_targets(
.await
.map_err(AppError::from)?;
let items: Vec<serde_json::Value> = rows.iter().map(|row| {
let mut items: Vec<serde_json::Value> = rows.iter().map(|row| {
use sqlx::Row;
let tonight_alt: f64 = row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default().unwrap_or(0.0);
let peak_alt: f64 = row.try_get::<Option<f64>, _>("seasonal_peak_alt").unwrap_or_default().unwrap_or(0.0);
let peak_date: Option<String> = row.try_get("seasonal_peak_date").unwrap_or_default();
let urgency: serde_json::Value = if peak_alt >= 15.0 && tonight_alt >= 15.0 {
let ratio = tonight_alt / peak_alt;
if ratio >= 0.90 {
serde_json::json!("peak")
} else if ratio >= 0.70 {
let before_peak = peak_date.as_deref().map(|d| d > today.as_str()).unwrap_or(true);
serde_json::json!(if before_peak { "rising" } else { "declining" })
} else {
serde_json::Value::Null
}
} else {
serde_json::Value::Null
};
serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
@@ -227,6 +341,8 @@ pub async fn list_targets(
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
@@ -234,7 +350,7 @@ pub async fn list_targets(
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
"max_alt_deg": row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"max_alt_deg": tonight_alt,
"usable_min": row.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
"transit_utc": row.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
"recommended_filter": row.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
@@ -243,6 +359,8 @@ pub async fn list_targets(
"moon_sep_deg": row.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
"is_visible_tonight": row.try_get::<Option<bool>, _>("is_visible_tonight").unwrap_or_default(),
"total_integration_min": row.try_get::<i64, _>("total_integration").unwrap_or(0),
"is_custom": false,
"urgency": urgency,
})
}).collect();
@@ -262,7 +380,82 @@ pub async fn list_targets(
for val in &bind_values {
count_query = count_query.bind(val);
}
let total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
let mut total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
// Append custom targets when show_custom is not explicitly false.
// Only on page 1, and only when not mosaic_only or not_imaged filter is active.
let show_custom = params.show_custom.unwrap_or(true);
if show_custom && page == 1 && !params.mosaic_only.unwrap_or(false) {
let custom_rows = sqlx::query(
"SELECT id, name, obj_type, ra_deg, dec_deg, notes FROM custom_targets WHERE ra_deg IS NOT NULL AND dec_deg IS NOT NULL ORDER BY created_at DESC",
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
if !custom_rows.is_empty() {
let jd = julian_date(chrono::Utc::now());
let lst = local_sidereal_time(jd, LON);
// Apply search filter to custom targets if active
let search_lower = params.search.as_deref().unwrap_or("").to_lowercase();
for row in &custom_rows {
use sqlx::Row;
let id: String = row.try_get("id").unwrap_or_default();
let name: String = row.try_get("name").unwrap_or_default();
if !search_lower.is_empty()
&& !id.to_lowercase().contains(&search_lower)
&& !name.to_lowercase().contains(&search_lower)
{
continue;
}
let ra: f64 = row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = row.try_get("obj_type").unwrap_or_else(|_| "custom".to_string());
let (alt, _az) = radec_to_altaz(ra, dec, lst, LAT);
let current_alt = (alt * 10.0).round() / 10.0;
items.push(serde_json::json!({
"id": &id,
"name": &name,
"common_name": serde_json::Value::Null,
"obj_type": &obj_type,
"ra_deg": ra,
"dec_deg": dec,
"ra_h": fmt_ra(ra),
"dec_dms": fmt_dec(dec),
"constellation": serde_json::Value::Null,
"size_arcmin_maj": serde_json::Value::Null,
"size_arcmin_min": serde_json::Value::Null,
"mag_v": serde_json::Value::Null,
"surface_brightness": serde_json::Value::Null,
"hubble_type": serde_json::Value::Null,
"messier_num": serde_json::Value::Null,
"is_highlight": false,
"fov_fill_pct": serde_json::Value::Null,
"mosaic_flag": false,
"mosaic_panels_w": 1,
"mosaic_panels_h": 1,
"difficulty": serde_json::Value::Null,
"guide_star_density": serde_json::Value::Null,
"max_alt_deg": current_alt,
"usable_min": serde_json::Value::Null,
"transit_utc": serde_json::Value::Null,
"recommended_filter": serde_json::Value::Null,
"best_start_utc": serde_json::Value::Null,
"best_end_utc": serde_json::Value::Null,
"moon_sep_deg": serde_json::Value::Null,
"is_visible_tonight": alt > 15.0,
"total_integration_min": 0,
"is_custom": true,
}));
total += 1;
}
}
}
Ok(Json(serde_json::json!({
"items": items,
@@ -276,12 +469,14 @@ pub async fn get_target(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
// Support both NGC/IC IDs and M-number IDs (e.g. "M42")
let m_num: Option<i32> = id.trim()
.strip_prefix(['M', 'm'])
.and_then(|n| n.parse().ok());
let row = if let Some(n) = m_num {
let catalog_row = if let Some(n) = m_num {
sqlx::query("SELECT * FROM catalog WHERE messier_num = ?")
.bind(n)
.fetch_optional(&state.pool)
@@ -291,34 +486,76 @@ pub async fn get_target(
.bind(&id)
.fetch_optional(&state.pool)
.await?
}
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
};
if let Some(row) = catalog_row {
return Ok(Json(serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
})));
}
// Fall back to custom_targets
let custom_row = sqlx::query("SELECT * FROM custom_targets WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
let ra: Option<f64> = custom_row.try_get("ra_deg").unwrap_or_default();
let dec: Option<f64> = custom_row.try_get("dec_deg").unwrap_or_default();
let ra_h = ra.map(fmt_ra).unwrap_or_default();
let dec_dms = dec.map(fmt_dec).unwrap_or_default();
use sqlx::Row;
Ok(Json(serde_json::json!({
"id": row.try_get::<String, _>("id").unwrap_or_default(),
"name": row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
"id": custom_row.try_get::<String, _>("id").unwrap_or_default(),
"name": custom_row.try_get::<String, _>("name").unwrap_or_default(),
"common_name": serde_json::Value::Null,
"obj_type": custom_row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
"ra_deg": ra.unwrap_or(0.0),
"dec_deg": dec.unwrap_or(0.0),
"ra_h": ra_h,
"dec_dms": dec_dms,
"constellation": serde_json::Value::Null,
"size_arcmin_maj": serde_json::Value::Null,
"size_arcmin_min": serde_json::Value::Null,
"pos_angle_deg": serde_json::Value::Null,
"mag_v": serde_json::Value::Null,
"surface_brightness": serde_json::Value::Null,
"hubble_type": serde_json::Value::Null,
"messier_num": serde_json::Value::Null,
"is_highlight": false,
"fov_fill_pct": serde_json::Value::Null,
"mosaic_flag": false,
"mosaic_panels_w": 1,
"mosaic_panels_h": 1,
"difficulty": serde_json::Value::Null,
"guide_star_density": serde_json::Value::Null,
"notes": custom_row.try_get::<Option<String>, _>("notes").unwrap_or_default(),
"is_custom": true,
})))
}
@@ -361,16 +598,9 @@ pub async fn get_visibility(
}
async fn compute_visibility_live(state: &AppState, id: &str) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(id)
.fetch_optional(&state.pool)
let (ra, dec, obj_type) = lookup_coords(&state.pool, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
let today = chrono::Utc::now().naive_utc().date();
let (dusk, dawn) = astro_twilight(today, LAT, LON)
@@ -421,15 +651,9 @@ pub async fn get_curve(
) -> Result<Json<serde_json::Value>, AppError> {
// Always compute live at 1-minute resolution for the interactive chart.
// The cached visibility_json uses 10-minute steps and lacks moon_alt_deg.
let cat_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (ra, dec, _obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
let date = chrono::Utc::now().naive_utc().date();
let (dusk, dawn) = astro_twilight(date, LAT, LON)
@@ -464,21 +688,17 @@ pub async fn get_filters(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
let tonight_row = sqlx::query(
"SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1",
)
.fetch_optional(&state.pool)
.await?;
use sqlx::Row;
let (moon_illum, moon_ra, moon_dec) = match tonight_row {
Some(r) => (
r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5),
@@ -491,19 +711,7 @@ pub async fn get_filters(
let now_jd = julian_date(chrono::Utc::now());
let moon_alt = moon_altitude(now_jd, LAT, LON);
let target_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
.await?;
let moon_sep = match target_row {
Some(r) => {
let ra: f64 = r.try_get("ra_deg").unwrap_or_default();
let dec: f64 = r.try_get("dec_deg").unwrap_or_default();
crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec)
}
None => 90.0,
};
let moon_sep = crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec);
let recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep);
Ok(Json(serde_json::json!({ "recommendations": recs })))
@@ -513,14 +721,9 @@ pub async fn get_workflow_handler(
State(state): State<AppState>,
Path((id, filter_id)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, AppError> {
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (_ra, _dec, obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
let workflow = get_workflow(&obj_type, &filter_id);
Ok(Json(serde_json::to_value(workflow).unwrap()))
}
@@ -540,16 +743,9 @@ pub async fn get_yearly(
time::local_sidereal_time,
};
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
.bind(&id)
.fetch_optional(&state.pool)
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
use sqlx::Row;
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
// Transit altitude: maximum the object can ever reach (constant for a DSO).
let transit_alt = (90.0 - (LAT - dec).abs()).max(0.0_f64).min(90.0_f64);
@@ -603,6 +799,71 @@ pub async fn get_yearly(
Ok(Json(serde_json::json!({ "catalog_id": id, "points": points })))
}
pub async fn get_similar(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
use sqlx::Row;
// Get the target's constellation, obj_type, and transit_utc from tonight's cache
let target_row = sqlx::query(
"SELECT c.constellation, c.obj_type, c.ra_deg, nc.transit_utc FROM catalog c LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime') WHERE c.id = ?",
)
.bind(&id)
.fetch_optional(&state.pool)
.await?;
let (constellation, obj_type, ra_deg, transit_utc): (Option<String>, String, f64, Option<String>) = match target_row {
Some(ref r) => (
r.try_get("constellation").unwrap_or(None),
r.try_get("obj_type").unwrap_or_default(),
r.try_get("ra_deg").unwrap_or_default(),
r.try_get("transit_utc").unwrap_or(None),
),
None => return Ok(Json(serde_json::json!({ "similar": [] }))),
};
// Find similar objects: same type + same constellation, ordered by RA proximity (≈ transit time proximity)
// RA difference of 15° ≈ 1h in transit time
let similar_rows = sqlx::query(
r#"SELECT c.id, c.name, c.common_name, c.obj_type, c.size_arcmin_maj,
c.fov_fill_pct, c.messier_num, nc.max_alt_deg, nc.transit_utc, nc.recommended_filter
FROM catalog c
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime')
WHERE c.id != ?
AND c.obj_type = ?
AND c.constellation = ?
AND ABS(c.ra_deg - ?) <= 25.0
AND nc.max_alt_deg >= 15
ORDER BY ABS(c.ra_deg - ?) ASC
LIMIT 5"#,
)
.bind(&id)
.bind(&obj_type)
.bind(&constellation)
.bind(ra_deg)
.bind(ra_deg)
.fetch_all(&state.pool)
.await?;
let similar: Vec<serde_json::Value> = similar_rows.iter().map(|r| {
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(),
"size_arcmin_maj": r.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
"fov_fill_pct": r.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
"messier_num": r.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
})
}).collect();
Ok(Json(serde_json::json!({ "similar": similar, "target_transit": transit_utc })))
}
pub async fn get_notes(
State(state): State<AppState>,
Path(id): Path<String>,