Compare commits
10 commits
2b5a39a550
...
1faaf4f65b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1faaf4f65b | ||
|
|
8c5450348a | ||
|
|
0431308cb4 | ||
|
|
eeeb380686 | ||
|
|
89f60e894b | ||
|
|
1b52caef65 | ||
|
|
b321811b35 | ||
|
|
0953abda86 | ||
|
|
cede60cfa9 | ||
|
|
235fbd226b |
9 changed files with 2952 additions and 28 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
mileage_log.json
|
||||
mileage_log.xlsx
|
||||
.env
|
||||
2069
Cargo.lock
generated
Normal file
2069
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
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"
|
||||
105
PLAN.md
105
PLAN.md
|
|
@ -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
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())
|
||||
}
|
||||
290
src/main.rs
Normal file
290
src/main.rs
Normal 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
346
templates/index.html
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue