Compare commits

..

10 commits

Author SHA1 Message Date
Claude
1faaf4f65b
Capture location at every step, not just step 1
- Show the location button on all three steps
- Send lat/lon to the server for every photo upload
- Clear gpsCoords after each upload so each step gets a fresh capture
- Store location_1 (step 1) and location_2 (step 2) in the session
- Ride 1 LogEntry uses location_1; Ride 2 LogEntry uses location_2

https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 20:56:45 +00:00
Claude
8c5450348a
Fix multipart upload failure: raise body limit and fix catch syntax
Axum 0.7 defaults to a 2 MB body limit, which rejects typical phone photos
(often 5–15 MB). Added DefaultBodyLimit::max(50 MB) to accept large images.

Also changed `catch {}` to `catch (e) {}` in the sessionStorage IIFE; the
optional-catch-binding syntax (ES2019) is not supported by all mobile
WebViews, which would have prevented the script from loading entirely.

https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 20:47:44 +00:00
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
Claude
eeeb380686
Show raw geolocation error code and message for debugging 2026-03-18 20:27:16 +00:00
Claude
89f60e894b
Fix geolocation on iOS Safari: explicit button + error feedback
Replace silent on-load getCurrentPosition with an opt-in button on
step 1. iOS Safari requires a user gesture for the permission prompt
to appear; calling it on page load suppresses the dialog. The button
triggers the request on tap, shows "Getting location…" while waiting,
and surfaces permission-denied / timeout errors with clear messages.

https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 20:16:16 +00:00
Claude
1b52caef65
Add Cargo.lock
https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 18:42:37 +00:00
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
Claude
0953abda86
Add GPS location column to plan
Browser captures GPS coords on step 1, server reverse-geocodes
via Nominatim, and location is stored in session + written to
both Excel rows.

https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 18:27:41 +00:00
Claude
cede60cfa9
Update plan for 3-photo / 2-ride session flow
Photo 2 serves as both end of ride 1 and start of ride 2.
Server holds in-memory session state across the 3 uploads
and writes both Excel rows after the final photo.

https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 18:24:22 +00:00
Claude
235fbd226b
Switch backend from Python/Flask to Rust/Axum
https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
2026-03-18 18:18:45 +00:00
9 changed files with 2952 additions and 28 deletions

4
.gitignore vendored Normal file
View file

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

2069
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
Cargo.toml Normal file
View 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"

105
PLAN.md
View file

@ -3,9 +3,12 @@
## How it works
1. Open the web app on your phone
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
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
5. Download the sheet anytime
---
@ -14,18 +17,20 @@
| Layer | Technology |
|-------------|-----------------------------|
| Backend | Python + Flask |
| Backend | Rust + Axum |
| AI / OCR | Claude API (vision) |
| Spreadsheet | openpyxl (Excel .xlsx) |
| Spreadsheet | rust_xlsxwriter (Excel) |
| Geocoding | browser Geolocation API + reverse-geocode REST API |
| Frontend | Mobile-friendly HTML |
---
## Excel sheet columns
| Date | Time | Odometer (km) | Trip (km) | Notes |
|------------|-------|---------------|-----------|-------|
| 2026-03-18 | 08:14 | 84 320 | 47 | Work |
| 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 |
---
@ -33,37 +38,81 @@
```
Driverthing/
├── app.py # Flask server + Claude API call
├── mileage.py # Excel read/write logic
├── src/
│ ├── main.rs # Axum server + routes + session state
│ ├── claude.rs # Claude API vision call
│ └── excel.rs # Excel read/write logic
├── templates/
│ └── index.html # Mobile camera upload page
├── requirements.txt
│ └── index.html # Mobile step-by-step camera upload UI
├── Cargo.toml
└── mileage_log.xlsx # Generated, gitignored
```
---
## Flow
## Session state (in-memory)
Between the 3 uploads the server holds a simple session with:
```
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
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
```
---
## Implementation steps
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
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

69
src/claude.rs Normal file
View 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
View 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
View 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())
}

290
src/main.rs Normal file
View file

@ -0,0 +1,290 @@
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![]),
}
}

346
templates/index.html Normal file
View file

@ -0,0 +1,346 @@
<!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>