use axum::{extract::State, Json}; use chrono::{NaiveDateTime, Utc}; use super::{AppError, AppState}; /// Find the 7timer dataseries slot closest to tonight's dusk UTC. /// Falls back to slot[0] (now) if dusk is unavailable. fn find_tonight_slot(dataseries: &[serde_json::Value], init_str: &str, dusk_utc: Option<&str>) -> Option { let dusk_utc = dusk_utc?; let dusk_dt = chrono::DateTime::parse_from_rfc3339(dusk_utc).ok()?; let dusk_epoch = dusk_dt.timestamp(); // 7timer init format: "2026040812" → 2026-04-08 12:00 UTC let init_dt = NaiveDateTime::parse_from_str(init_str, "%Y%m%d%H") .ok() .map(|dt| dt.and_utc().timestamp())?; let mut best: Option<&serde_json::Value> = None; let mut best_diff = i64::MAX; for slot in dataseries { let tp = slot.get("timepoint")?.as_i64()?; let slot_epoch = init_dt + tp * 3600; let diff = (slot_epoch - dusk_epoch).abs(); if diff < best_diff { best_diff = diff; best = Some(slot); } } best.cloned() } pub async fn get_weather( State(state): State, ) -> Result, AppError> { let row = sqlx::query("SELECT * FROM weather_cache WHERE id = 1") .fetch_optional(&state.pool) .await?; match row { Some(r) => { use sqlx::Row; let seventimer_json: Option = r.try_get("seventimer_json").unwrap_or_default(); let parsed_7t = seventimer_json.as_deref() .and_then(|s| serde_json::from_str::(s).ok()); let dataseries = parsed_7t.as_ref() .and_then(|v| v.get("dataseries")) .and_then(|v| v.as_array()) .map(|a| a.as_slice()) .unwrap_or(&[]); let init_str = parsed_7t.as_ref() .and_then(|v| v.get("init")) .and_then(|v| v.as_str()) .unwrap_or(""); // Load tonight's dusk to pick the relevant forecast slot let dusk_utc: Option = sqlx::query_scalar( "SELECT astro_dusk_utc FROM tonight WHERE id = 1" ) .fetch_optional(&state.pool) .await .unwrap_or(None); // Try to get the slot nearest to tonight's dusk; fall back to first slot let tonight_slot = find_tonight_slot(dataseries, init_str, dusk_utc.as_deref()) .or_else(|| dataseries.first().cloned()); // Also keep current slot (slot[0]) for actual current conditions let current_slot = dataseries.first().cloned(); let dew_alert = { let temp = r.try_get::, _>("temp_c").unwrap_or_default().unwrap_or(20.0); let dew = r.try_get::, _>("dew_point_c").unwrap_or_default().unwrap_or(10.0); let margin = temp - dew; if margin < 2.0 { Some("critical") } else if margin < 4.0 { Some("warning") } else { None } }; let go_nogo_str = r.try_get::, _>("go_nogo").unwrap_or_default(); // Build go_nogo_reasons from tonight's slot let slot_for_reasons = tonight_slot.as_ref().or(current_slot.as_ref()); let go_nogo_reasons = slot_for_reasons.map(|slot| { let mut reasons = Vec::::new(); if let Some(cc) = slot.get("cloudcover").and_then(|v| v.as_i64()) { if cc > 4 { reasons.push(format!("Cloud cover {}/9", cc)); } } if let Some(see) = slot.get("seeing").and_then(|v| v.as_i64()) { if see > 5 { reasons.push(format!("Poor seeing ({}/8)", see)); } } if let Some(tr) = slot.get("transparency").and_then(|v| v.as_i64()) { if tr > 5 { reasons.push(format!("Low transparency ({}/8)", tr)); } } if let Some(li) = slot.get("lifted_index").and_then(|v| v.as_i64()) { if li < -2 { reasons.push(format!("Unstable atmosphere (LI {})", li)); } } reasons }).unwrap_or_default(); // Recompute go/nogo from tonight's slot let tonight_go_nogo = tonight_slot.as_ref().map(|slot| { let cc = slot.get("cloudcover").and_then(|v| v.as_i64()).unwrap_or(5); let see = slot.get("seeing").and_then(|v| v.as_i64()).unwrap_or(5); let tr = slot.get("transparency").and_then(|v| v.as_i64()).unwrap_or(5); if cc <= 2 && see <= 3 && tr <= 3 { "go" } else if cc <= 4 && see <= 5 { "marginal" } else { "nogo" } }).or(go_nogo_str.as_deref()); let s = tonight_slot.as_ref(); Ok(Json(serde_json::json!({ "dew_point_c": r.try_get::, _>("dew_point_c").unwrap_or_default(), "temp_c": r.try_get::, _>("temp_c").unwrap_or_default(), "humidity_pct": r.try_get::, _>("humidity_pct").unwrap_or_default(), "go_nogo": tonight_go_nogo, "go_nogo_reasons": go_nogo_reasons, "fetched_at": r.try_get::, _>("fetched_at").unwrap_or_default(), "dew_alert": dew_alert, // Tonight's forecast slot fields "cloudcover": s.and_then(|s| s.get("cloudcover")).and_then(|v| v.as_i64()), "seeing": s.and_then(|s| s.get("seeing")).and_then(|v| v.as_i64()), "transparency": s.and_then(|s| s.get("transparency")).and_then(|v| v.as_i64()), "lifted_index": s.and_then(|s| s.get("lifted_index")).and_then(|v| v.as_i64()), "wind10m": s.and_then(|s| s.get("wind10m")).cloned(), "rh2m": s.and_then(|s| s.get("rh2m")).and_then(|v| v.as_i64()), }))) } None => Ok(Json(serde_json::json!({ "go_nogo": null, "fetched_at": null }))), } } pub async fn get_forecast( State(state): State, ) -> Result, AppError> { let row = sqlx::query("SELECT seventimer_json FROM weather_cache WHERE id = 1") .fetch_optional(&state.pool) .await?; let forecast = row .and_then(|r| { use sqlx::Row; r.try_get::, _>("seventimer_json").ok().flatten() }) .and_then(|s| serde_json::from_str::(&s).ok()) .unwrap_or(serde_json::json!({})); Ok(Json(forecast)) }