use axum::{ body::Body, extract::{Multipart, Query, State}, http::{header, StatusCode}, response::{Html, Json, Response}, routing::{get, post}, Router, }; use chrono::Local; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; use tokio::fs; mod claude; mod excel; mod geocode; #[derive(Default)] struct Session { reading_1: Option, reading_2: Option, date: Option, time_1: Option, // start of ride 1 time_2: Option, // end of ride 1 / start of ride 2 location: Option, } type AppState = Arc>; #[derive(Serialize, Deserialize, Clone)] pub struct LogEntry { pub date: String, pub time: String, pub odometer_start: u32, pub odometer_end: u32, pub trip_km: u32, pub location: String, pub notes: String, } #[derive(Deserialize)] struct UploadQuery { step: u8, } #[derive(Serialize)] struct UploadResponse { step: u8, reading: u32, #[serde(skip_serializing_if = "Option::is_none")] location: Option, #[serde(skip_serializing_if = "Option::is_none")] ride1_km: Option, #[serde(skip_serializing_if = "Option::is_none")] ride2_km: Option, done: bool, } #[tokio::main] async fn main() { let state: AppState = Arc::new(Mutex::new(Session::default())); let app = Router::new() .route("/", get(index)) .route("/upload", post(upload)) .route("/download", get(download)) .route("/reset", post(reset_session)) .with_state(state); let addr = "0.0.0.0:3000"; println!("Listening on http://{addr}"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } async fn index() -> Html<&'static str> { Html(include_str!("../templates/index.html")) } async fn reset_session(State(state): State) -> StatusCode { *state.lock().unwrap() = Session::default(); StatusCode::OK } async fn upload( State(state): State, Query(q): Query, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { let mut image_bytes: Vec = Vec::new(); let mut lat: Option = None; let mut lon: Option = None; while let Some(field) = multipart .next_field() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { let name = field.name().unwrap_or("").to_string(); match name.as_str() { "image" => { image_bytes = field .bytes() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? .to_vec(); } "lat" => { lat = field.text().await.ok().and_then(|t| t.parse().ok()); } "lon" => { lon = field.text().await.ok().and_then(|t| t.parse().ok()); } _ => {} } } if image_bytes.is_empty() { return Err((StatusCode::BAD_REQUEST, "No image provided".into())); } let reading = claude::read_odometer(&image_bytes) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let now = Local::now(); let date_str = now.format("%Y-%m-%d").to_string(); let time_str = now.format("%H:%M").to_string(); match q.step { 1 => { // Reverse-geocode before acquiring the lock (async operation) let location = if let (Some(la), Some(lo)) = (lat, lon) { geocode::reverse_geocode(la, lo) .await .unwrap_or_else(|_| format!("{:.5}, {:.5}", la, lo)) } else { String::new() }; { let mut s = state.lock().unwrap(); s.reading_1 = Some(reading); s.date = Some(date_str); s.time_1 = Some(time_str); s.location = Some(location.clone()); } Ok(Json(UploadResponse { step: 1, reading, location: Some(location), ride1_km: None, ride2_km: None, done: false, })) } 2 => { let (reading_1, location) = { let mut s = state.lock().unwrap(); s.reading_2 = Some(reading); s.time_2 = Some(time_str); (s.reading_1, s.location.clone()) }; let ride1_km = reading_1.map(|r1| reading.saturating_sub(r1)); Ok(Json(UploadResponse { step: 2, reading, location, ride1_km, ride2_km: None, done: false, })) } 3 => { // Read everything we need from the session and release the lock let (r1, r2, date, time_1, time_2, location) = { let s = state.lock().unwrap(); ( s.reading_1, s.reading_2, s.date.clone(), s.time_1.clone(), s.time_2.clone(), s.location.clone().unwrap_or_default(), ) }; let (r1, r2) = match (r1, r2) { (Some(a), Some(b)) => (a, b), _ => { return Err(( StatusCode::BAD_REQUEST, "Session incomplete — restart from step 1".into(), )) } }; let r3 = reading; let entries = vec![ LogEntry { date: date.clone().unwrap_or_default(), time: time_1.unwrap_or_default(), odometer_start: r1, odometer_end: r2, trip_km: r2.saturating_sub(r1), location: location.clone(), notes: "Ride 1".into(), }, LogEntry { date: date.unwrap_or_default(), time: time_2.unwrap_or_default(), odometer_start: r2, odometer_end: r3, trip_km: r3.saturating_sub(r2), location: location.clone(), notes: "Ride 2".into(), }, ]; append_entries(&entries) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; *state.lock().unwrap() = Session::default(); Ok(Json(UploadResponse { step: 3, reading: r3, location: Some(location), ride1_km: Some(r2.saturating_sub(r1)), ride2_km: Some(r3.saturating_sub(r2)), done: true, })) } _ => Err((StatusCode::BAD_REQUEST, "step must be 1, 2, or 3".into())), } } async fn download() -> Result { let entries = load_entries().await.unwrap_or_default(); let bytes = excel::generate(&entries) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let response = Response::builder() .header( header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) .header( header::CONTENT_DISPOSITION, "attachment; filename=\"mileage_log.xlsx\"", ) .body(Body::from(bytes)) .unwrap(); Ok(response) } async fn append_entries(new_entries: &[LogEntry]) -> anyhow::Result<()> { let mut all = load_entries().await.unwrap_or_default(); all.extend_from_slice(new_entries); fs::write("mileage_log.json", serde_json::to_string_pretty(&all)?).await?; Ok(()) } async fn load_entries() -> anyhow::Result> { match fs::read_to_string("mileage_log.json").await { Ok(s) => Ok(serde_json::from_str(&s)?), Err(_) => Ok(vec![]), } }