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
This commit is contained in:
parent
0953abda86
commit
b321811b35
7 changed files with 776 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/target
|
||||||
|
mileage_log.json
|
||||||
|
mileage_log.xlsx
|
||||||
|
.env
|
||||||
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "driverthing"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "driverthing"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", features = ["multipart"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = "0.4"
|
||||||
|
rust_xlsxwriter = "0.75"
|
||||||
|
anyhow = "1"
|
||||||
69
src/claude.rs
Normal file
69
src/claude.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub async fn read_odometer(image_bytes: &[u8]) -> anyhow::Result<u32> {
|
||||||
|
let api_key =
|
||||||
|
std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY env var not set")?;
|
||||||
|
|
||||||
|
let media_type = detect_media_type(image_bytes);
|
||||||
|
let b64 = STANDARD.encode(image_bytes);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-haiku-4-5-20251001",
|
||||||
|
"max_tokens": 64,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": media_type,
|
||||||
|
"data": b64
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "What is the odometer reading in this photo? Reply with only the number (digits only, no spaces, dots, or commas)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp: Value = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", &api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to reach Claude API")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse Claude API response")?;
|
||||||
|
|
||||||
|
let text = resp["content"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.context("No text in Claude response")?;
|
||||||
|
|
||||||
|
// Keep only digits in case the model adds any punctuation
|
||||||
|
let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||||
|
digits
|
||||||
|
.parse::<u32>()
|
||||||
|
.with_context(|| format!("Could not parse odometer number from: {text:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_media_type(bytes: &[u8]) -> &'static str {
|
||||||
|
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||||
|
"image/jpeg"
|
||||||
|
} else if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
|
||||||
|
"image/png"
|
||||||
|
} else if bytes.get(8..12) == Some(b"WEBP") {
|
||||||
|
"image/webp"
|
||||||
|
} else {
|
||||||
|
"image/jpeg" // safe fallback for phone cameras
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/excel.rs
Normal file
37
src/excel.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
use crate::LogEntry;
|
||||||
|
use rust_xlsxwriter::{Format, Workbook, XlsxError};
|
||||||
|
|
||||||
|
pub fn generate(entries: &[LogEntry]) -> Result<Vec<u8>, XlsxError> {
|
||||||
|
let mut workbook = Workbook::new();
|
||||||
|
let sheet = workbook.add_worksheet();
|
||||||
|
|
||||||
|
let bold = Format::new().set_bold();
|
||||||
|
|
||||||
|
let headers = [
|
||||||
|
"Date",
|
||||||
|
"Time",
|
||||||
|
"Odometer start (km)",
|
||||||
|
"Odometer end (km)",
|
||||||
|
"Trip (km)",
|
||||||
|
"Location",
|
||||||
|
"Notes",
|
||||||
|
];
|
||||||
|
for (col, h) in headers.iter().enumerate() {
|
||||||
|
sheet.write_with_format(0, col as u16, *h, &bold)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row, e) in entries.iter().enumerate() {
|
||||||
|
let r = (row + 1) as u32;
|
||||||
|
sheet.write(r, 0, e.date.as_str())?;
|
||||||
|
sheet.write(r, 1, e.time.as_str())?;
|
||||||
|
sheet.write(r, 2, e.odometer_start as f64)?;
|
||||||
|
sheet.write(r, 3, e.odometer_end as f64)?;
|
||||||
|
sheet.write(r, 4, e.trip_km as f64)?;
|
||||||
|
sheet.write(r, 5, e.location.as_str())?;
|
||||||
|
sheet.write(r, 6, e.notes.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.autofit();
|
||||||
|
|
||||||
|
workbook.save_to_buffer()
|
||||||
|
}
|
||||||
41
src/geocode.rs
Normal file
41
src/geocode.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NominatimResponse {
|
||||||
|
display_name: Option<String>,
|
||||||
|
address: Option<NominatimAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NominatimAddress {
|
||||||
|
city: Option<String>,
|
||||||
|
town: Option<String>,
|
||||||
|
village: Option<String>,
|
||||||
|
country_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reverse_geocode(lat: f64, lon: f64) -> anyhow::Result<String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp: NominatimResponse = client
|
||||||
|
.get("https://nominatim.openstreetmap.org/reverse")
|
||||||
|
.query(&[
|
||||||
|
("lat", lat.to_string()),
|
||||||
|
("lon", lon.to_string()),
|
||||||
|
("format", "json".to_string()),
|
||||||
|
])
|
||||||
|
.header("User-Agent", "Driverthing/1.0")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(addr) = resp.address {
|
||||||
|
let city = addr.city.or(addr.town).or(addr.village).unwrap_or_default();
|
||||||
|
let cc = addr.country_code.unwrap_or_default().to_uppercase();
|
||||||
|
if !city.is_empty() {
|
||||||
|
return Ok(format!("{city}, {cc}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp.display_name.unwrap_or_default())
|
||||||
|
}
|
||||||
277
src/main.rs
Normal file
277
src/main.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
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![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
329
templates/index.html
Normal file
329
templates/index.html
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<title>Driverthing</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.4rem; color: #1a1a2e; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: #999; font-size: 0.85rem; margin-bottom: 24px; }
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.dot.active { background: #4f8ef7; }
|
||||||
|
.dot.done { background: #34c759; }
|
||||||
|
.step-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background: #f8f9ff;
|
||||||
|
border: 1px solid #e0e7ff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #4a5580;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.reading {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4f8ef7;
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
}
|
||||||
|
.gps-tag {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #388e3c;
|
||||||
|
background: #e8f5e9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
label.cam-btn {
|
||||||
|
display: block;
|
||||||
|
background: #4f8ef7;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-top: 6px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
label.cam-btn:active { background: #3a7be0; }
|
||||||
|
input[type="file"] { display: none; }
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn:active { opacity: 0.8; }
|
||||||
|
.btn-green { background: #34c759; color: white; }
|
||||||
|
.btn-gray { background: #ebebeb; color: #555; }
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #eee;
|
||||||
|
border-top: 3px solid #4f8ef7;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 14px auto;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.error {
|
||||||
|
color: #e53e3e;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.divider { height: 1px; background: #f0f0f0; margin: 14px 0; }
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 7px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
.summary-row:last-child { border-bottom: none; }
|
||||||
|
.summary-label { color: #888; }
|
||||||
|
.summary-value { font-weight: 600; color: #1a1a2e; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Driverthing</h1>
|
||||||
|
<p class="subtitle">Mileage tracker</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="dot" id="d1"></div>
|
||||||
|
<div class="dot" id="d2"></div>
|
||||||
|
<div class="dot" id="d3"></div>
|
||||||
|
</div>
|
||||||
|
<div id="content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let gpsCoords = null;
|
||||||
|
|
||||||
|
// Request GPS immediately so it's ready when we need it
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
pos => { gpsCoords = { lat: pos.coords.latitude, lon: pos.coords.longitude }; },
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dots(active, doneUpTo) {
|
||||||
|
[1, 2, 3].forEach(i => {
|
||||||
|
const el = document.getElementById('d' + i);
|
||||||
|
el.className = 'dot';
|
||||||
|
if (i <= doneUpTo) el.classList.add('done');
|
||||||
|
else if (i === active) el.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(html) {
|
||||||
|
document.getElementById('content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step screens ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showStep(n) {
|
||||||
|
const labels = {
|
||||||
|
1: 'Start of ride 1',
|
||||||
|
2: 'End of ride 1 / Start of ride 2',
|
||||||
|
3: 'End of ride 2',
|
||||||
|
};
|
||||||
|
dots(n, n - 1);
|
||||||
|
render(`
|
||||||
|
<div class="step-label">Step ${n} of 3</div>
|
||||||
|
<div class="step-title">${labels[n]}</div>
|
||||||
|
<div class="info-box">Point your camera at the odometer and take a photo.</div>
|
||||||
|
<label class="cam-btn" for="img">
|
||||||
|
📸 Open camera
|
||||||
|
<input type="file" id="img" accept="image/*" capture="environment"
|
||||||
|
onchange="handleUpload(this, ${n})">
|
||||||
|
</label>
|
||||||
|
<div id="status"></div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleUpload(input, step) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
document.getElementById('status').innerHTML = '<div class="spinner"></div>';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image', file);
|
||||||
|
if (step === 1 && gpsCoords) {
|
||||||
|
form.append('lat', gpsCoords.lat);
|
||||||
|
form.append('lon', gpsCoords.lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/upload?step=' + step, { method: 'POST', body: form });
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (step === 1) afterStep1(data);
|
||||||
|
else if (step === 2) afterStep2(data);
|
||||||
|
else afterStep3(data);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('status').innerHTML =
|
||||||
|
`<div class="error">⚠ ${e.message}<br><small>Try taking the photo again.</small></div>`;
|
||||||
|
// Re-enable the input so the user can retry
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── After step 1: show reading + GPS, then offer step 2 ─────────────────
|
||||||
|
|
||||||
|
function afterStep1(data) {
|
||||||
|
dots(2, 1);
|
||||||
|
render(`
|
||||||
|
<div class="step-label">Step 1 done ✓</div>
|
||||||
|
<div class="step-title">Odometer read</div>
|
||||||
|
<div class="reading">${data.reading.toLocaleString()} km</div>
|
||||||
|
${data.location ? `<span class="gps-tag">📍 ${data.location}</span>` : ''}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="step-label">Step 2 of 3</div>
|
||||||
|
<div class="step-title">End of ride 1 / Start of ride 2</div>
|
||||||
|
<div class="info-box">Take a photo when you have arrived and are about to start ride 2.</div>
|
||||||
|
<label class="cam-btn" for="img">
|
||||||
|
📸 Open camera
|
||||||
|
<input type="file" id="img" accept="image/*" capture="environment"
|
||||||
|
onchange="handleUpload(this, 2)">
|
||||||
|
</label>
|
||||||
|
<div id="status"></div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── After step 2: show ride 1 result + offer step 3 ─────────────────────
|
||||||
|
|
||||||
|
function afterStep2(data) {
|
||||||
|
dots(3, 2);
|
||||||
|
render(`
|
||||||
|
<div class="step-label">Step 2 done ✓</div>
|
||||||
|
<div class="step-title">Ride 1: ${data.ride1_km} km</div>
|
||||||
|
<div class="reading">${data.reading.toLocaleString()} km</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="step-label">Step 3 of 3</div>
|
||||||
|
<div class="step-title">End of ride 2</div>
|
||||||
|
<div class="info-box">Take a photo of the odometer at the end of your second ride.</div>
|
||||||
|
<label class="cam-btn" for="img">
|
||||||
|
📸 Open camera
|
||||||
|
<input type="file" id="img" accept="image/*" capture="environment"
|
||||||
|
onchange="handleUpload(this, 3)">
|
||||||
|
</label>
|
||||||
|
<div id="status"></div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── After step 3: summary + download ────────────────────────────────────
|
||||||
|
|
||||||
|
function afterStep3(data) {
|
||||||
|
dots(3, 3);
|
||||||
|
const total = data.ride1_km + data.ride2_km;
|
||||||
|
render(`
|
||||||
|
<div class="step-label">All done ✓</div>
|
||||||
|
<div class="step-title">Both rides saved</div>
|
||||||
|
<div style="height:8px"></div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Ride 1</span>
|
||||||
|
<span class="summary-value">${data.ride1_km} km</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Ride 2</span>
|
||||||
|
<span class="summary-value">${data.ride2_km} km</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Total today</span>
|
||||||
|
<span class="summary-value">${total} km</span>
|
||||||
|
</div>
|
||||||
|
${data.location ? `
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Location</span>
|
||||||
|
<span class="summary-value">${data.location}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
<div style="height:10px"></div>
|
||||||
|
<a class="btn btn-green" href="/download">⬇ Download Excel</a>
|
||||||
|
<button class="btn btn-gray" onclick="startOver()">Start new session</button>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function startOver() {
|
||||||
|
await fetch('/reset', { method: 'POST' });
|
||||||
|
gpsCoords = null;
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
pos => { gpsCoords = { lat: pos.coords.latitude, lon: pos.coords.longitude }; },
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
showStep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||||
|
showStep(1);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue