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
343 lines
11 KiB
HTML
343 lines
11 KiB
HTML
<!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 { 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 = n === 1 ? `
|
|
<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 (step === 1 && gpsCoords) {
|
|
form.append('lat', gpsCoords.lat);
|
|
form.append('lon', gpsCoords.lon);
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/upload?step=' + step, { method: 'POST', body: form });
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
const data = await resp.json();
|
|
|
|
if (step === 1) afterStep1(data);
|
|
else if (step === 2) afterStep2(data);
|
|
else afterStep3(data);
|
|
|
|
} catch (e) {
|
|
document.getElementById('status').innerHTML =
|
|
`<div class="error">⚠ ${e.message}<br><small>Try taking the photo again.</small></div>`;
|
|
// Re-enable the input so the user can retry
|
|
input.value = '';
|
|
}
|
|
}
|
|
|
|
// ── After step 1: show reading + GPS, then offer step 2 ─────────────────
|
|
|
|
function afterStep1(data) {
|
|
dots(2, 1);
|
|
render(`
|
|
<div class="step-label">Step 1 done ✓</div>
|
|
<div class="step-title">Odometer read</div>
|
|
<div class="reading">${data.reading.toLocaleString()} km</div>
|
|
${data.location ? `<span class="gps-tag">📍 ${data.location}</span>` : ''}
|
|
<div class="divider"></div>
|
|
<div class="step-label">Step 2 of 3</div>
|
|
<div class="step-title">End of ride 1 / Start of ride 2</div>
|
|
<div class="info-box">Take a photo when you have arrived and are about to start ride 2.</div>
|
|
<label class="cam-btn" for="img">
|
|
📸 Open camera
|
|
<input type="file" id="img" accept="image/*" capture="environment"
|
|
onchange="handleUpload(this, 2)">
|
|
</label>
|
|
<div id="status"></div>
|
|
`);
|
|
}
|
|
|
|
// ── After step 2: show ride 1 result + offer step 3 ─────────────────────
|
|
|
|
function afterStep2(data) {
|
|
dots(3, 2);
|
|
render(`
|
|
<div class="step-label">Step 2 done ✓</div>
|
|
<div class="step-title">Ride 1: ${data.ride1_km} km</div>
|
|
<div class="reading">${data.reading.toLocaleString()} km</div>
|
|
<div class="divider"></div>
|
|
<div class="step-label">Step 3 of 3</div>
|
|
<div class="step-title">End of ride 2</div>
|
|
<div class="info-box">Take a photo of the odometer at the end of your second ride.</div>
|
|
<label class="cam-btn" for="img">
|
|
📸 Open camera
|
|
<input type="file" id="img" accept="image/*" capture="environment"
|
|
onchange="handleUpload(this, 3)">
|
|
</label>
|
|
<div id="status"></div>
|
|
`);
|
|
}
|
|
|
|
// ── After step 3: summary + download ────────────────────────────────────
|
|
|
|
function afterStep3(data) {
|
|
dots(3, 3);
|
|
const total = data.ride1_km + data.ride2_km;
|
|
render(`
|
|
<div class="step-label">All done ✓</div>
|
|
<div class="step-title">Both rides saved</div>
|
|
<div style="height:8px"></div>
|
|
<div class="summary-row">
|
|
<span class="summary-label">Ride 1</span>
|
|
<span class="summary-value">${data.ride1_km} km</span>
|
|
</div>
|
|
<div class="summary-row">
|
|
<span class="summary-label">Ride 2</span>
|
|
<span class="summary-value">${data.ride2_km} km</span>
|
|
</div>
|
|
<div class="summary-row">
|
|
<span class="summary-label">Total today</span>
|
|
<span class="summary-value">${total} km</span>
|
|
</div>
|
|
${data.location ? `
|
|
<div class="summary-row">
|
|
<span class="summary-label">Location</span>
|
|
<span class="summary-value">${data.location}</span>
|
|
</div>` : ''}
|
|
<div style="height:10px"></div>
|
|
<a class="btn btn-green" href="/download">⬇ Download Excel</a>
|
|
<button class="btn btn-gray" onclick="startOver()">Start new session</button>
|
|
`);
|
|
}
|
|
|
|
// ── Reset ────────────────────────────────────────────────────────────────
|
|
|
|
async function startOver() {
|
|
await fetch('/reset', { method: 'POST' });
|
|
gpsCoords = null;
|
|
sessionStorage.removeItem('gpsCoords');
|
|
showStep(1);
|
|
}
|
|
|
|
// ── Boot ─────────────────────────────────────────────────────────────────
|
|
showStep(1);
|
|
</script>
|
|
</body>
|
|
</html>
|