Driverthing/src/main.rs
Claude b321811b35
Implement mileage tracking app (Rust + Axum + Claude Vision)
- Axum server with 3-step multipart upload flow
- Claude Haiku Vision API reads odometer from phone photos
- Step 1: photo + GPS → reverse-geocoded location via Nominatim
- Step 2: mid-session reading, shows ride 1 km immediately
- Step 3: final reading, writes 2 rows to JSON log, resets session
- GET /download generates and streams mileage_log.xlsx
- Mobile-friendly step-by-step HTML UI with progress indicator
- Excel columns: Date, Time, Odometer start/end, Trip km, Location, Notes

https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 18:42:12 +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_default()
} 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![]),
}
}