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