Driverthing/src/main.rs
Claude 0431308cb4
Persist GPS coords in sessionStorage; fall back to raw coords on geocode failure
iOS Safari can discard page JS state when backgrounding to open the camera.
Saving gpsCoords to sessionStorage ensures the coords survive the round-trip
and are still available when handleUpload runs after the photo is taken.

Also change the server-side geocode fallback from empty string to raw lat/lon
so the location column in the Excel sheet is never silently empty.

https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 20:36:32 +00:00

277 lines
8 KiB
Rust

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<u32>,
reading_2: Option<u32>,
date: Option<String>,
time_1: Option<String>, // start of ride 1
time_2: Option<String>, // end of ride 1 / start of ride 2
location: Option<String>,
}
type AppState = Arc<Mutex<Session>>;
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ride1_km: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
ride2_km: Option<u32>,
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<AppState>) -> StatusCode {
*state.lock().unwrap() = Session::default();
StatusCode::OK
}
async fn upload(
State(state): State<AppState>,
Query(q): Query<UploadQuery>,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
let mut image_bytes: Vec<u8> = Vec::new();
let mut lat: Option<f64> = None;
let mut lon: Option<f64> = 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<Response, (StatusCode, String)> {
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<Vec<LogEntry>> {
match fs::read_to_string("mileage_log.json").await {
Ok(s) => Ok(serde_json::from_str(&s)?),
Err(_) => Ok(vec![]),
}
}