Compare commits

..

No commits in common. "1faaf4f65b473b3fc1b31bc211cd97984a77140c" and "2b5a39a550b8ebed013d1b584b591cda5ff467d1" have entirely different histories.

9 changed files with 28 additions and 2952 deletions

4
.gitignore vendored
View file

@ -1,4 +0,0 @@
/target
mileage_log.json
mileage_log.xlsx
.env

2069
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
[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"

105
PLAN.md
View file

@ -3,12 +3,9 @@
## How it works ## How it works
1. Open the web app on your phone 1. Open the web app on your phone
2. Take **3 photos** in sequence: 2. Take a photo of the odometer
- **Photo 1** — odometer at the start of ride 1 3. Claude Vision API reads the number automatically
- **Photo 2** — odometer at the end of ride 1 / start of ride 2 4. The km driven is calculated and saved to an Excel sheet
- **Photo 3** — odometer at the end of ride 2
3. Claude Vision API reads each number automatically
4. Two trip rows are calculated and saved to the Excel sheet
5. Download the sheet anytime 5. Download the sheet anytime
--- ---
@ -17,20 +14,18 @@
| Layer | Technology | | Layer | Technology |
|-------------|-----------------------------| |-------------|-----------------------------|
| Backend | Rust + Axum | | Backend | Python + Flask |
| AI / OCR | Claude API (vision) | | AI / OCR | Claude API (vision) |
| Spreadsheet | rust_xlsxwriter (Excel) | | Spreadsheet | openpyxl (Excel .xlsx) |
| Geocoding | browser Geolocation API + reverse-geocode REST API |
| Frontend | Mobile-friendly HTML | | Frontend | Mobile-friendly HTML |
--- ---
## Excel sheet columns ## Excel sheet columns
| Date | Time | Odometer start (km) | Odometer end (km) | Trip (km) | Location | Notes | | Date | Time | Odometer (km) | Trip (km) | Notes |
|------------|-------|---------------------|-------------------|-----------|-----------------|--------| |------------|-------|---------------|-----------|-------|
| 2026-03-18 | 08:14 | 84 273 | 84 320 | 47 | Amsterdam, NL | Ride 1 | | 2026-03-18 | 08:14 | 84 320 | 47 | Work |
| 2026-03-18 | 09:05 | 84 320 | 84 391 | 71 | Haarlem, NL | Ride 2 |
--- ---
@ -38,81 +33,37 @@
``` ```
Driverthing/ Driverthing/
├── src/ ├── app.py # Flask server + Claude API call
│ ├── main.rs # Axum server + routes + session state ├── mileage.py # Excel read/write logic
│ ├── claude.rs # Claude API vision call
│ └── excel.rs # Excel read/write logic
├── templates/ ├── templates/
│ └── index.html # Mobile step-by-step camera upload UI │ └── index.html # Mobile camera upload page
├── Cargo.toml ├── requirements.txt
└── mileage_log.xlsx # Generated, gitignored └── mileage_log.xlsx # Generated, gitignored
``` ```
--- ---
## Session state (in-memory) ## Flow
Between the 3 uploads the server holds a simple session with:
``` ```
session { Phone camera → upload photo
reading_1: Option<u32> # start of ride 1 → Flask receives image
reading_2: Option<u32> # end of ride 1 / start of ride 2 → Claude Vision: "What is the odometer reading?"
reading_3: Option<u32> # end of ride 2 → Extract number
location: Option<String> # reverse-geocoded from GPS at step 1 → Calculate delta from last reading
} → Append row to Excel
``` → Show confirmation on screen
When all three readings are present, two rows are written to Excel and the
session is cleared.
---
## UI flow (single page, steps replace each other)
```
Step 1: "Take photo of odometer — START of ride 1"
[Camera button] → upload (browser also sends GPS coords)
↓ server reads: 84 273 ✓
↓ GPS reverse-geocoded: "Amsterdam, NL" ✓
Step 2: "Take photo of odometer — END of ride 1 / START of ride 2"
[Camera button] → upload
↓ server reads: 84 320 ✓
↓ Ride 1: 47 km (shown on screen)
Step 3: "Take photo of odometer — END of ride 2"
[Camera button] → upload
↓ server reads: 84 391 ✓
↓ Ride 2: 71 km (shown on screen)
↓ Both rows saved ✓
Done screen: summary + [Download Excel] + [Start new session]
```
---
## Flow (technical)
```
POST /upload?step=1 → read photo + GPS coords → reverse-geocode location
→ store reading_1 + location in session
POST /upload?step=2 → read photo → store reading_2 → calc ride1 delta → show
POST /upload?step=3 → read photo → store reading_3 → calc ride2 delta
→ write 2 rows to Excel (both with same location) → return summary
GET /download → serve mileage_log.xlsx
``` ```
--- ---
## Implementation steps ## Implementation steps
1. Set up Axum server with in-memory session state (DashMap or Mutex<HashMap>) 1. Set up Flask app with a file upload endpoint
2. Create a single `/upload` endpoint that accepts `step=1|2|3` + multipart image + optional lat/lon form fields 2. Send uploaded image to Claude API with a vision prompt
3. Send each uploaded image (base64) to Claude Vision API: "What is the odometer reading in km? Reply with only the number." 3. Parse the odometer number from the response
4. Parse the integer from the response and store it in the session 4. Read the last recorded odometer value from the Excel file
5. At step 1: reverse-geocode lat/lon via a free API (e.g. nominatim.openstreetmap.org) → store city/address in session 5. Calculate the difference (km driven)
6. After step 2: calculate ride 1 km, return intermediate confirmation 6. Append a new row (date, time, odometer, trip km, notes) to the sheet
7. After step 3: calculate ride 2 km, append both rows (with location) to the Excel file, clear session 7. Return confirmation to the browser
8. Mobile-friendly step-by-step HTML UI: request GPS on load, attach coords to each upload, progress indicator 8. Add a download button for the Excel file
9. `GET /download` endpoint to serve the Excel file

View file

@ -1,69 +0,0 @@
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
}
}

View file

@ -1,37 +0,0 @@
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()
}

View file

@ -1,41 +0,0 @@
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())
}

View file

@ -1,290 +0,0 @@
use axum::{
body::Body,
extract::{DefaultBodyLimit, 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_1: Option<String>, // location at step 1 (start of ride 1)
location_2: Option<String>, // location at step 2 (start of ride 2)
}
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))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50 MB phone photos can be large
.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_1 = Some(location.clone());
}
Ok(Json(UploadResponse {
step: 1,
reading,
location: Some(location),
ride1_km: None,
ride2_km: None,
done: false,
}))
}
2 => {
// 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 reading_1 = {
let mut s = state.lock().unwrap();
s.reading_2 = Some(reading);
s.time_2 = Some(time_str);
s.location_2 = Some(location.clone());
s.reading_1
};
let ride1_km = reading_1.map(|r1| reading.saturating_sub(r1));
Ok(Json(UploadResponse {
step: 2,
reading,
location: Some(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_1, location_2) = {
let s = state.lock().unwrap();
(
s.reading_1,
s.reading_2,
s.date.clone(),
s.time_1.clone(),
s.time_2.clone(),
s.location_1.clone().unwrap_or_default(),
s.location_2.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_1,
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_2.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_2),
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![]),
}
}

View file

@ -1,346 +0,0 @@
<!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>
// Restore coords if iOS discarded the page while camera was open
let gpsCoords = (() => {
try { return JSON.parse(sessionStorage.getItem('gpsCoords')); } catch (e) { return null; }
})();
function getLocation() {
const el = document.getElementById('loc-status');
if (!navigator.geolocation) {
el.textContent = 'Location not supported by this browser.';
return;
}
el.innerHTML = '<span style="color:#888">Getting location…</span>';
navigator.geolocation.getCurrentPosition(
pos => {
gpsCoords = { lat: pos.coords.latitude, lon: pos.coords.longitude };
sessionStorage.setItem('gpsCoords', JSON.stringify(gpsCoords));
el.innerHTML = '<span style="color:#388e3c">📍 Location captured</span>';
},
err => {
el.innerHTML = `<span style="color:#e53e3e">Error ${err.code}: ${err.message}</span>`;
},
{ timeout: 15000 }
);
}
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);
const locBlock = `
<button class="btn btn-gray" style="margin-bottom:10px" onclick="getLocation()">📍 Share location (optional)</button>
<div id="loc-status" style="font-size:0.85rem;text-align:center;min-height:1.2em;margin-bottom:6px"></div>`;
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>
${locBlock}
<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 (gpsCoords) {
form.append('lat', gpsCoords.lat);
form.append('lon', gpsCoords.lon);
}
// Clear so next step starts fresh
gpsCoords = null;
sessionStorage.removeItem('gpsCoords');
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;
sessionStorage.removeItem('gpsCoords');
showStep(1);
}
// ── Boot ─────────────────────────────────────────────────────────────────
showStep(1);
</script>
</body>
</html>