From 24ecddd03488c3b004ef6a50bbcd5e0938b8eb1f Mon Sep 17 00:00:00 2001 From: Steven Polley Date: Sun, 11 Jan 2026 17:16:59 -0700 Subject: [PATCH] initial commit --- .gitea/workflows/pedestrian-simulator.yaml | 46 + .gitignore | 4 + Dockerfile | 11 + README.md | 94 +- data/README.md | 17 + frontend/app.js | 1517 ++++++++++++++++++++ frontend/index.html | 200 +++ frontend/privacy.html | 170 +++ frontend/style.css | 813 +++++++++++ go.mod | 3 + pedestrian-simulator.code-workspace | 8 + server/context.go | 18 + server/fitbit.go | 296 ++++ server/kml.go | 595 ++++++++ server/main.go | 134 ++ server/session.go | 159 ++ server/step_manager.go | 269 ++++ server/user.go | 156 ++ 18 files changed, 4506 insertions(+), 4 deletions(-) create mode 100644 .gitea/workflows/pedestrian-simulator.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 data/README.md create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/privacy.html create mode 100644 frontend/style.css create mode 100644 go.mod create mode 100644 pedestrian-simulator.code-workspace create mode 100644 server/context.go create mode 100644 server/fitbit.go create mode 100644 server/kml.go create mode 100644 server/main.go create mode 100644 server/session.go create mode 100644 server/step_manager.go create mode 100644 server/user.go diff --git a/.gitea/workflows/pedestrian-simulator.yaml b/.gitea/workflows/pedestrian-simulator.yaml new file mode 100644 index 0000000..33bcb3d --- /dev/null +++ b/.gitea/workflows/pedestrian-simulator.yaml @@ -0,0 +1,46 @@ +name: pedestrian-simulator + +on: + push: + branches: + - main + - master + schedule: + - cron: "0 0 * * 5" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.x" + cache: true # enables Go module + build caching automatically + + - name: Build server + env: + GOOS: linux + GOARCH: amd64 + CGO_ENABLED: 0 + run: | + cd server + go version + go build -a -ldflags "-w" + cp server ../pedestrian-simulator + cd .. + cp "$(go env GOROOT)/lib/time/zoneinfo.zip" . + cp /etc/ssl/certs/ca-certificates.crt . + + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: registry.stevenpolley.net/pedestrian-simulator:latest + no-cache: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..527fdd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +env.sh +*.exe +*.json +*.kml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55cc792 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM scratch +LABEL maintainer="himself@stevenpolley.net" +COPY zoneinfo.zip zoneinfo.zip +COPY ca-certificates.crt /etc/ssl/certs/ +COPY pedestrian-simulator . +COPY frontend frontend + +EXPOSE 8080 + +ENV ZONEINFO zoneinfo.zip +CMD [ "./pedestrian-simulator" ] diff --git a/README.md b/README.md index c986512..6f1ff1d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,91 @@ -# pedestrian-simulator - -Pedestrian Simulator is a unique step visualization app that simulates walking through Google Maps Street View based on your actual step count from fitbit. You can walk from and to anywhere you like in the world, or you can create, share and use public walking paths in street view. +# Pedestrian Simulator - Google Street View Walking Simulator -Be outside while you are inside. The future is now! \ No newline at end of file +A unique step visualization application that breathes life into your daily fitness data. Pedestrian Simulator syncs with your Fitbit account and "walks" you through Google Street View based on the actual steps you take. + +## 🌍 The Concept + +Pedestrian Simulator transforms your real-world progress into a virtual journey. Whether you're walking across town or across the globe, the application converts your Fitbit step count into distance and animates a path through Google Street View, providing a first-person perspective of your progress. + +## ✨ Key Features + +- **🚀 Real-Time Fitbit Syncing**: Automatically fetches your latest step data from Fitbit using OAuth 2.0. +- **🚶 Smooth Walking Animation**: High FPS (60+) animation transitions between Street View panoramas, creating a fluid walking experience. +- **🎥 Natural Camera Movement**: Realistic head movement simulation with: + - Anchored direction-of-travel panning. + - Subtle pitch (up/down) variations. + - Gentle "breathing" motion when idle. +- **🗺️ Multiple View Modes**: Cycle between Street View, Satellite, and Map modes for different perspectives of your route. +- **📂 Custom KML Support**: + - Upload your own KML routes (e.g., from Google My Maps). + - Browse and download routes shared by others. + - Upvote and downvote public routes to highlight the best journeys. +- **💾 Persistent Progress**: Your route progress, API keys, and settings are saved automatically. +- **📡 Live Status Bar**: Real-time tracking of steps taken, distance covered, and time until the next data sync. +- **📍 Interactive Minimap**: A dedicated, dark-themed minimap shows your exact position on the global route. + +## 🛠️ Tech Stack + +- **Backend**: Go (Golang) + - Custom Step Manager for logic and synchronization. + - Robust KML parser and shared route registry. + - Secure session management and Fitbit OAuth integration. +- **Frontend**: Vanilla JavaScript (ES6+) + - Modern CSS with dark mode and glassmorphism. + - Google Maps JavaScript API for Street View and mapping. + +## 🚀 Getting Started + +### 1. Prerequisites + +- A [Google Maps JavaScript API Key](https://console.cloud.google.com/) with: + - Maps JavaScript API + - Street View Static API + - Directions API +- A [Fitbit Developer Account](https://dev.fitbit.com/) to create an application and obtain OAuth credentials. + +### 2. Environment Variables + +Set up your Fitbit OAuth credentials: + +```bash +export FITBIT_CLIENT_ID="your_client_id" +export FITBIT_CLIENT_SECRET="your_client_secret" +``` + +### 3. Installation & Usage + +1. **Clone the repository**: + ```bash + git clone https://code.stevenpolley.net/steven/pedestrian-simulator.git + cd pedestrian-simulator + ``` + +2. **Run the server**: + ```bash + cd server + go run . + ``` + +3. **Launch the application**: + Open `http://localhost:8080` in your browser. + +4. **Configuration**: + - Log in with your Fitbit account. + - Enter your Google Maps API Key when prompted. + - Enter a start and destination, or browse for a shared KML route to begin! + +## 🎮 Controls & Hotkeys + +- **`Space`**: Manually trigger a step sync from Fitbit. +- **`M`**: Cycle through View Modes (Street View → Map → Satellite). +- **`D`**: "Drain" pending steps (instantly animate through all remaining steps). +- **`✎` (Route Info)**: Click the route indicator in the header to reset your current trip and choose a new path. + +## 📁 Project Structure + +- `server/`: Go backend handles API requests, authentication, and state management. +- `frontend/`: Vanilla JS application, styles, and assets. +- `data/`: Persistent storage for user sessions, route metadata, and uploaded KML files. + +--- +*Go outside while staying inside. The future is now.* diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..149f736 --- /dev/null +++ b/data/README.md @@ -0,0 +1,17 @@ +# Persistent data storage + +## data/sessions + +This directory contains session data for the application. Each session is stored in a separate file. + +## data/users + +This directory contains user data for the application. Each user is stored in a separate file. + +## kml_votes.json + +This file contains the KML votes for the application. It is a JSON file that contains an array of objects, each with a filename and a vote count. + +## users.json + +This file contains the users for the application. It is a JSON file that contains an array of objects, each with a username and a password. \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..f112b55 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,1517 @@ +// Constants +const METERS_PER_STEP = 0.762; // Average step length +const SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +const API_KEY_STORAGE = 'pedestrian_simulator_apikey'; +const LOCATION_STORAGE = 'pedestrian_simulator_location'; + +// Global state +let map; +let panorama; +let directionsService; +let masterPath = []; // Route coordinates +let routeTotalDistance = 0; // Total route distance in meters +let routeStartIndex = 0; // Where we are in the master path based on index +let currentPathIndex = 0; +// Minimap globals +// Minimap globals +let minimap; +let minimapMarker; +let minimapPolyline; +// Main Map globals +let mainMap; +let mainMapMarker; +let mainMapPolyline; +let viewMode = 0; // 0: Street View, 1: Map View, 2: Satellite View +let targetSyncTime = null; +let idleAnimationId = null; + + +let apiKey = null; +window.isGeneratingRoute = false; + + + + +// Camera movement +let baseHeading = 0; // Direction of travel +let cameraTime = 0; // Time variable for smooth oscillations +let lastFrameTime = null; // For calculating delta time +let headingOscillationSpeed = 0.1; // How fast the head turns (radians per second) +let headingRange = 80; // Max degrees to look left/right from center +let pitchOscillationSpeed = 0.2; // Pitch movement speed +let pitchRange = 8; // Max degrees to look up/down +let basePitch = 0; // Neutral pitch (slightly down for natural walking view) + +// Initialize app +window.addEventListener('load', async () => { + // Check authentication status first + const isAuthenticated = await checkAuth(); + + if (!isAuthenticated) { + // Show login overlay + document.getElementById('login-overlay').classList.add('active'); + setupLoginHandler(); + return; + } + + // User is authenticated, proceed with normal init + // Check for saved API key + apiKey = localStorage.getItem(API_KEY_STORAGE); + + if (apiKey) { + document.getElementById('apikey-overlay').classList.remove('active'); + loadGoogleMapsAPI(); + } else { + setupApiKeyHandler(); + } + + setupEventListeners(); + setupKMLBrowser(); + startStatusPolling(); +}); + +// ======================================== +// Authentication Functions +// ======================================== + +async function checkAuth() { + try { + const response = await fetch('/api/status'); + if (response.status === 401) { + return false; + } + if (response.ok) { + const data = await response.json(); + updateUserProfile(data.user); + return true; + } + return false; + } catch (error) { + console.error('Auth check failed:', error); + return false; + } +} + +function updateUserProfile(user) { + if (!user) return; + const nameEl = document.getElementById('userName'); + const avatarEl = document.getElementById('userAvatar'); + const defaultAvatar = 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png'; + + if (nameEl && user.displayName) nameEl.textContent = user.displayName; + if (avatarEl) { + avatarEl.src = user.avatarUrl || defaultAvatar; + avatarEl.onerror = () => { avatarEl.src = defaultAvatar; }; + } +} + +function setupLoginHandler() { + document.getElementById('fitbitLoginButton').addEventListener('click', () => { + window.location.href = '/auth/fitbit'; + }); +} + +async function setupUserMenu() { + // This function could fetch user info and display it + // For now, the backend handles this via the Fitbit callback + const userMenu = document.getElementById('user-menu'); + + document.getElementById('logoutButton').addEventListener('click', () => { + window.location.href = '/auth/logout'; + }); + + document.getElementById('kmlBrowserButton').addEventListener('click', () => { + openKMLBrowser(); + }); + + userMenu.style.display = 'flex'; +} + +// ======================================== +// KML Browser Functions +// ======================================== + +function setupKMLBrowser() { + // Setup user menu + setupUserMenu(); + + // Tab switching + document.querySelectorAll('.kml-tab').forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.tab; + switchKMLTab(tabName); + }); + }); + + // Close button + document.getElementById('closeKmlBrowser').addEventListener('click', () => { + document.getElementById('kml-browser-overlay').classList.remove('active'); + }); + + // Upload button + document.getElementById('uploadKmlButton').addEventListener('click', () => { + document.getElementById('kmlUploadInput').click(); + }); + + document.getElementById('kmlUploadInput').addEventListener('change', handleKMLUpload); + + // Sort controls + document.getElementById('myFilesSortSelect').addEventListener('change', () => loadKMLFiles()); + document.getElementById('publicFilesSortSelect').addEventListener('change', () => loadKMLFiles()); +} + +function openKMLBrowser() { + document.getElementById('kml-browser-overlay').classList.add('active'); + loadKMLFiles(); +} + +function switchKMLTab(tabName) { + // Update tab buttons + document.querySelectorAll('.kml-tab').forEach(t => t.classList.remove('active')); + document.querySelector(`.kml-tab[data-tab="${tabName}"]`).classList.add('active'); + + // Update tab content + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + document.getElementById(`${tabName}-tab`).classList.add('active'); +} + +async function handleKMLUpload(e) { + const file = e.target.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('kml', file); + + try { + const response = await fetch('/api/kml/upload', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const data = await response.json(); + alert(`KML file uploaded successfully! Distance: ${data.distance.toFixed(2)} km`); + e.target.value = ''; // Reset input + loadKMLFiles(); // Refresh list + } else { + const error = await response.text(); + alert(`Upload failed: ${error}`); + } + } catch (error) { + console.error('Upload error:', error); + alert('Upload failed. Please try again.'); + } +} + +async function loadKMLFiles() { + try { + const response = await fetch('/api/kml/list'); + if (!response.ok) { + console.error('Failed to load KML files'); + return; + } + + const data = await response.json(); + + // Render my files + renderKMLFiles('myFilesList', data.my_files || [], true); + + // Render public files + renderKMLFiles('publicFilesList', data.public_files || [], false); + } catch (error) { + console.error('Error loading KML files:', error); + } +} + +function renderKMLFiles(listId, files, isOwnFiles) { + const listElement = document.getElementById(listId); + + console.log(`[KML] Rendering ${files.length} files into ${listId}`); + listElement.innerHTML = files.map((file, index) => createKMLFileHTML(file, isOwnFiles, listId, index)).join(''); + + // Attach event listeners + files.forEach((file, index) => { + const uniqueId = `${listId}-${index}`; + + try { + // Use This Route button + const useBtn = document.getElementById(`use-${uniqueId}`); + if (useBtn) { + useBtn.addEventListener('click', (e) => { + console.log(`[KML] 'Use' click for: ${file.filename}`); + handleUseRoute(file); + }); + } + + // Vote buttons (only for public files) + if (!isOwnFiles) { + const upvoteBtn = document.getElementById(`upvote-${uniqueId}`); + const downvoteBtn = document.getElementById(`downvote-${uniqueId}`); + + if (upvoteBtn) upvoteBtn.addEventListener('click', () => handleVote(file, 1)); + if (downvoteBtn) downvoteBtn.addEventListener('click', () => handleVote(file, -1)); + } + + // Privacy toggle (only for own files) + if (isOwnFiles) { + const privacyBtn = document.getElementById(`privacy-${uniqueId}`); + if (privacyBtn) { + console.log(`[KML] Attaching Privacy listener for: ${file.filename}`); + privacyBtn.addEventListener('click', () => { + console.log(`[KML] 'Privacy' click for: ${file.filename}`); + handlePrivacyToggle(file); + }); + } + } + + // Delete button (only for own files) + if (isOwnFiles) { + const deleteBtn = document.getElementById(`delete-${uniqueId}`); + if (deleteBtn) { + console.log(`[KML] Attaching Delete listener for: ${file.filename}`); + deleteBtn.addEventListener('click', (e) => { + console.log(`[KML] 'Delete' click detected for: ${file.filename}`); + handleDelete(file); + }); + } + } + } catch (err) { + console.error(`[KML] Failed to attach listeners for ${file.filename}:`, err); + } + }); +} + +function createKMLFileHTML(file, isOwnFiles, listId, index) { + const uniqueId = `${listId}-${index}`; + + return ` +
+
+
${escapeHtml(file.filename)}
+
+ 📏 ${file.distance.toFixed(2)} km + ${!isOwnFiles ? `👤 ${escapeHtml(file.display_name)}` : ''} + ${file.is_public ? '🌍 Public' : '🔒 Private'} +
+
+
+ + ${!isOwnFiles ? ` + + + ` : ` + + + `} +
+
+ `; +} + +async function handleUseRoute(file) { + try { + const response = await fetch(`/api/kml/download?owner_id=${file.user_id}&filename=${encodeURIComponent(file.filename)}`); + if (!response.ok) { + alert('Failed to download KML file'); + return; + } + + const kmlContent = await response.text(); + processKML(kmlContent, file.filename); + + // Close browser + document.getElementById('kml-browser-overlay').classList.remove('active'); + } catch (error) { + console.error('Error selecting route:', error); + alert('Failed to select route'); + } +} + +async function handleVote(file, voteValue) { + try { + const response = await fetch('/api/kml/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_id: file.user_id, + filename: file.filename, + vote: voteValue + }) + }); + + if (response.ok) { + loadKMLFiles(); // Refresh to show updated votes + } else { + alert('Failed to vote'); + } + } catch (error) { + console.error('Vote error:', error); + } +} + +async function handlePrivacyToggle(file) { + try { + const response = await fetch('/api/kml/privacy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: file.filename }) + }); + + if (response.ok) { + loadKMLFiles(); // Refresh + } else { + alert('Failed to toggle privacy'); + } + } catch (error) { + console.error('Privacy toggle error:', error); + } +} + +/** + * Shows a custom confirmation overlay + * @returns {Promise} + */ +function showConfirm({ title, message, confirmText = 'Yes', cancelText = 'Cancel', isDanger = false }) { + return new Promise((resolve) => { + const overlay = document.getElementById('confirm-overlay'); + const titleEl = document.getElementById('confirmTitle'); + const messageEl = document.getElementById('confirmMessage'); + const confirmBtn = document.getElementById('generalConfirmButton'); + const cancelBtn = document.getElementById('generalCancelButton'); + + titleEl.textContent = title; + messageEl.textContent = message; + confirmBtn.textContent = confirmText; + cancelBtn.textContent = cancelText; + + if (isDanger) { + confirmBtn.className = 'primary-btn danger'; + } else { + confirmBtn.className = 'primary-btn'; + } + + const cleanup = () => { + overlay.classList.remove('active'); + confirmBtn.removeEventListener('click', onConfirm); + cancelBtn.removeEventListener('click', onCancel); + }; + + const onConfirm = () => { + cleanup(); + resolve(true); + }; + + const onCancel = () => { + cleanup(); + resolve(false); + }; + + confirmBtn.addEventListener('click', onConfirm); + cancelBtn.addEventListener('click', onCancel); + + overlay.classList.add('active'); + }); +} + +async function handleDelete(file) { + console.log('[KML] handleDelete function called for:', file.filename); + + const confirmed = await showConfirm({ + title: '🗑️ Delete File', + message: `Are you sure you want to delete "${file.filename}"? This cannot be undone.`, + confirmText: 'Yes, Delete', + isDanger: true + }); + + if (!confirmed) { + console.log('[KML] Delete cancelled by user'); + return; + } + + console.log('[KML] Proceeding with deletion of:', file.filename); + try { + const response = await fetch('/api/kml/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: file.filename }) + }); + + if (response.ok) { + loadKMLFiles(); // Refresh + } else { + alert('Failed to delete file'); + } + } catch (error) { + console.error('Delete error:', error); + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ======================================== +// Original Functions +// ======================================== + +function setupApiKeyHandler() { + const saveBtn = document.getElementById('saveApiKeyButton'); + const input = document.getElementById('apiKeyInput'); + + saveBtn.addEventListener('click', () => { + const key = input.value.trim(); + if (key) { + apiKey = key; + localStorage.setItem(API_KEY_STORAGE, key); + document.getElementById('apikey-overlay').classList.remove('active'); + loadGoogleMapsAPI(); + } else { + alert('Please enter a valid API key'); + } + }); + + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + saveBtn.click(); + } + }); +} + +function loadGoogleMapsAPI() { + if (window.google && window.google.maps) { + initializeMap(); + return; + } + + const script = document.createElement('script'); + script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=geometry`; + script.async = true; + script.defer = true; + script.onload = initializeMap; + script.onerror = () => { + alert('Failed to load Google Maps API. Please check your API key.'); + localStorage.removeItem(API_KEY_STORAGE); + location.reload(); + }; + document.head.appendChild(script); +} + +function initializeMap() { + // Check for saved location + const savedLocation = localStorage.getItem(LOCATION_STORAGE); + if (savedLocation) { + const locationData = JSON.parse(savedLocation); + const startLatLng = new google.maps.LatLng(locationData.startLat, locationData.startLng); + + // We need to reconstruct masterPath if possible, but we don't save the whole path. + // For now, let's just re-calculate the route on load if we have start/end addresses + if (locationData.isCustom && locationData.customPath) { + // Restore custom route + startFromCustomRoute(locationData.customPath, locationData.routeName, true); + } else if (locationData.startAddress && locationData.endAddress) { + document.getElementById('routeInfo').textContent = `${locationData.startAddress} ➝ ${locationData.endAddress}`; + calculateAndStartRoute(locationData.startAddress, locationData.endAddress, true); // isRestoring = true + } else { + // Legacy fallback + startFromLocation(startLatLng, locationData.address || "Saved Location"); + } + document.getElementById('setup-overlay').classList.remove('active'); + } +} + +function setupEventListeners() { + // Start button + document.getElementById('startButton').addEventListener('click', () => { + const startInput = document.getElementById('startLocationInput').value; + const endInput = document.getElementById('endLocationInput').value; + + if (startInput && endInput) { + calculateAndStartRoute(startInput, endInput); + } else { + alert('Please enter both a start and destination location'); + } + }); + + // Enter key on location inputs + const handleEnter = (e) => { + if (e.key === 'Enter') { + document.getElementById('startButton').click(); + } + }; + document.getElementById('startLocationInput').addEventListener('keypress', handleEnter); + document.getElementById('endLocationInput').addEventListener('keypress', handleEnter); + + // Route Info Click -> Open Reset Confirmation + document.getElementById('routeInfoGroup').addEventListener('click', async () => { + if (localStorage.getItem(LOCATION_STORAGE)) { + const confirmed = await showConfirm({ + title: '⚠️ Confirm Reset', + message: 'Are you sure you want to reset your location? This will end your current trip.', + confirmText: 'Yes, Reset', + isDanger: true + }); + + if (confirmed) { + resetLocation(); + } + } + }); + + // Space bar hotkey for refresh + document.addEventListener('keydown', (e) => { + // Ignore if typing in an input field + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + + // Only allow hotkeys when a trip is active + if (!localStorage.getItem(LOCATION_STORAGE)) { + return; + } + + if (e.code === 'Space') { + triggerRefresh(); + } + if (e.key.toLowerCase() === 'm') { + cycleViewMode(); + } + if (e.key.toLowerCase() === 'd') { + triggerDrain(); + } + }); + + // Refresh Button + const refreshBtn = document.getElementById('refreshButton'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + triggerRefresh(); + }); + } + + // Browse KML Files Button + const browseKmlBtn = document.getElementById('browseKmlButton'); + if (browseKmlBtn) { + browseKmlBtn.addEventListener('click', () => { + openKMLBrowser(); + }); + } +} + +async function calculateAndStartRoute(startAddress, endAddress, isRestoring = false) { + if (!window.google) return; + + // Use Directions Service to finding route + if (!directionsService) { + directionsService = new google.maps.DirectionsService(); + } + const request = { + origin: startAddress, + destination: endAddress, + travelMode: google.maps.TravelMode.WALKING + }; + + directionsService.route(request, (result, status) => { + if (status === 'OK') { + const route = result.routes[0]; + const leg = route.legs[0]; + + // Extract full path + masterPath = []; + routeTotalDistance = 0; + + route.legs.forEach(leg => { + routeTotalDistance += leg.distance.value; + leg.steps.forEach(step => { + step.path.forEach(point => { + masterPath.push(point); + }); + }); + }); + + const locationData = { + startAddress: leg.start_address, + endAddress: leg.end_address, + startLat: leg.start_location.lat(), + startLng: leg.start_location.lng() + }; + + // Save state + localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData)); + + // Initialize step reference + // Only start a new trip on server if this is a fresh start, not a restore + if (!isRestoring) { + fetch('/api/trip', { method: 'POST' }).catch(console.error); + } + + // Reset display steps for visual consistency + displayTripSteps = 0; + stateBuffer = []; + + startFromLocation(leg.start_location, locationData.startAddress); + document.getElementById('setup-overlay').classList.remove('active'); + + // Update UI + document.getElementById('routeInfo').textContent = `${leg.start_address} ➝ ${leg.end_address}`; + + // Draw on Minimap + if (minimap) { + if (minimapPolyline) minimapPolyline.setMap(null); + minimapPolyline = new google.maps.Polyline({ + path: masterPath, + geodesic: true, + strokeColor: '#6366f1', + strokeOpacity: 1.0, + strokeWeight: 4 + }); + minimapPolyline.setMap(minimap); + + // Fit bounds + const bounds = new google.maps.LatLngBounds(); + masterPath.forEach(pt => bounds.extend(pt)); + minimap.fitBounds(bounds); + } + + // Draw on Main Map if initialized + if (mainMap) { + if (mainMapPolyline) mainMapPolyline.setMap(null); + mainMapPolyline = new google.maps.Polyline({ + path: masterPath, + geodesic: true, + strokeColor: '#6366f1', + strokeOpacity: 0.8, + strokeWeight: 5 + }); + mainMapPolyline.setMap(mainMap); + } + + // Initial trip meter update + updateTripMeter(0); + + } else { + alert('Could not calculate route: ' + status); + } + }); +} + +function startFromLocation(locationLatLng, address) { + const location = locationLatLng; + + // Initialize Street View panorama + panorama = new google.maps.StreetViewPanorama( + document.getElementById('panorama'), + { + position: location, + pov: { heading: 0, pitch: 0 }, + zoom: 1, + disableDefaultUI: true, + linksControl: false, + clickToGo: false, + scrollwheel: false, + disableDoubleClickZoom: true, + showRoadLabels: true, + motionTracking: false, + motionTrackingControl: false + } + ); + + // Initialize Minimap + minimap = new google.maps.Map(document.getElementById('minimap'), { + center: location, + zoom: 14, + disableDefaultUI: true, + styles: [ + { elementType: "geometry", stylers: [{ color: "#242f3e" }] }, + { elementType: "labels.text.stroke", stylers: [{ color: "#242f3e" }] }, + { elementType: "labels.text.fill", stylers: [{ color: "#746855" }] }, + { + featureType: "administrative.locality", + elementType: "labels.text.fill", + stylers: [{ color: "#d59563" }], + }, + { + featureType: "poi", + elementType: "labels.text.fill", + stylers: [{ color: "#d59563" }], + }, + { + featureType: "poi.park", + elementType: "geometry", + stylers: [{ color: "#263c3f" }], + }, + { + featureType: "poi.park", + elementType: "labels.text.fill", + stylers: [{ color: "#6b9a76" }], + }, + { + featureType: "road", + elementType: "geometry", + stylers: [{ color: "#38414e" }], + }, + { + featureType: "road", + elementType: "geometry.stroke", + stylers: [{ color: "#212a37" }], + }, + { + featureType: "road", + elementType: "labels.text.fill", + stylers: [{ color: "#9ca5b3" }], + }, + { + featureType: "road.highway", + elementType: "geometry", + stylers: [{ color: "#746855" }], + }, + { + featureType: "road.highway", + elementType: "geometry.stroke", + stylers: [{ color: "#1f2835" }], + }, + { + featureType: "road.highway", + elementType: "labels.text.fill", + stylers: [{ color: "#f3d19c" }], + }, + { + featureType: "water", + elementType: "geometry", + stylers: [{ color: "#17263c" }], + }, + { + featureType: "water", + elementType: "labels.text.fill", + stylers: [{ color: "#515c6d" }], + }, + { + featureType: "water", + elementType: "labels.text.stroke", + stylers: [{ color: "#17263c" }], + }, + ], + }); + + minimapMarker = new google.maps.Marker({ + position: location, + map: minimap, + icon: { + path: google.maps.SymbolPath.CIRCLE, + scale: 7, + fillColor: '#6366f1', + fillOpacity: 1, + strokeColor: '#ffffff', + strokeWeight: 2, + }, + }); + + // Initialize directions service + directionsService = new google.maps.DirectionsService(); + + // Start camera animation loop + startCameraAnimation(); + + console.log('Street View initialized at:', address); +} + +function resetLocation() { + localStorage.removeItem(LOCATION_STORAGE); + document.getElementById('setup-overlay').classList.add('active'); + document.getElementById('startLocationInput').value = ''; + document.getElementById('endLocationInput').value = ''; + stopAnimation(); +} + +// Rate limiting for refresh +let lastRefreshTime = 0; +const MIN_REFRESH_INTERVAL = 10000; // 10 seconds + +async function triggerRefresh() { + const now = Date.now(); + if (now - lastRefreshTime < MIN_REFRESH_INTERVAL) { + console.log("Refresh rate limited"); + return; + } + + lastRefreshTime = now; + + // UI Feedback + const refreshBtn = document.getElementById('refreshButton'); + if (refreshBtn) refreshBtn.classList.add('spinning'); + + try { + await fetch('/api/refresh', { method: 'POST' }); + // Immediately fetch status to update view + await fetchStatus(); + } catch (err) { + console.error("Refresh failed", err); + } finally { + if (refreshBtn) refreshBtn.classList.remove('spinning'); + } +} + +async function triggerDrain() { + console.log("Triggering interpolation drain..."); + try { + await fetch('/api/drain', { method: 'POST' }); + // Immediately fetch status to update view + await fetchStatus(); + } catch (err) { + console.error("Drain trigger failed", err); + } +} + +// Step tracking +// Using a 6-second delay buffer to ensure smooth interpolation between past known points. +// This prevents overshooting/guessing. +const INTERPOLATION_DELAY = 6000; // 6 seconds +let stateBuffer = []; // Array of {t: timestamp, s: steps} +let displayTripSteps = 0; // Current interpolated value + + +// Fetch status from backend +async function fetchStatus() { + try { + const response = await fetch('/api/status'); + const data = await response.json(); + const now = Date.now(); + + // Update target steps from server + if (data.tripSteps !== undefined) { + // Add to buffer + stateBuffer.push({ t: now, s: data.tripSteps }); + + // Keep buffer size manageable (20 points is ~1.5 mins) + if (stateBuffer.length > 20) { + stateBuffer.shift(); + } + } + + // Update user info + updateUserProfile(data.user); + + // Update next sync countdown + const nextSyncTime = new Date(data.nextSyncTime); + targetSyncTime = nextSyncTime; + updateNextSyncCountdown(targetSyncTime); + + } catch (error) { + console.error('Error fetching status:', error); + } +} + +function updateNextSyncCountdown(nextSyncTime) { + const now = new Date(); + const diff = nextSyncTime - now; + + if (diff <= 0) { + document.getElementById('nextSync').textContent = 'Syncing...'; + return; + } + + const minutes = Math.floor(diff / 60000); + const seconds = Math.floor((diff % 60000) / 1000); + document.getElementById('nextSync').textContent = + `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function startStatusPolling() { + fetchStatus(); + setInterval(fetchStatus, 5000); // Poll every 5 seconds + + // Update countdown every second + setInterval(() => { + if (targetSyncTime) { + updateNextSyncCountdown(targetSyncTime); + } + }, 1000); +} + +function updateTripMeter(distanceTraveled) { + const traveledKm = (distanceTraveled / 1000).toFixed(2); + const totalKm = (routeTotalDistance / 1000).toFixed(2); + document.getElementById('tripMeter').textContent = `${traveledKm} / ${totalKm} km`; +} + +let streetViewService; + +function updatePositionAlongPath(distanceMeters) { + if (!masterPath || masterPath.length < 2) return; + + // Iterate through path segments to find our current spot + let distanceCovered = 0; + + for (let i = 0; i < masterPath.length - 1; i++) { + const p1 = masterPath[i]; + const p2 = masterPath[i + 1]; + const segmentDist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2); + + if (distanceCovered + segmentDist >= distanceMeters) { + // We are on this segment + const remainingDist = distanceMeters - distanceCovered; + const heading = google.maps.geometry.spherical.computeHeading(p1, p2); + const targetPos = google.maps.geometry.spherical.computeOffset(p1, remainingDist, heading); + + // Look ahead to next point + // Smoothly update baseHeading for idle animation + // We set baseHeading here to ensure we face the direction of travel + baseHeading = heading; + window.isAutoUpdatingCamera = true; + + // Use StreetViewService to find the nearest valid panorama + if (!streetViewService) { + streetViewService = new google.maps.StreetViewService(); + } + + // We use a radius of 50 meters to find a snap point + const request = { + location: targetPos, + preference: google.maps.StreetViewPreference.NEAREST, + radius: 50, + source: google.maps.StreetViewSource.OUTDOOR + }; + + streetViewService.getPanorama(request, (data, status) => { + const now = Date.now(); + // REGARDLESS of success or failure, we update the timestamp to prevent + // immediate retries. This acts as our rate limit/backoff. + window.lastPanoUpdate = now; + + if (status === 'OK') { + const foundPanoId = data.location.pano; + // Only update if we are moving to a new node to avoid slight jitters + if (panorama.getPano() !== foundPanoId) { + panorama.setPano(foundPanoId); + + // Update minimap to the ACTUAL location found, not just the theoretical one + const foundLoc = data.location.latLng; + if (minimapMarker) { + minimapMarker.setPosition(foundLoc); + if (minimap && minimap.getBounds() && !minimap.getBounds().contains(foundLoc)) { + minimap.panTo(foundLoc); + } + } + + // Sync Main Map if it exists + if (mainMapMarker) { + mainMapMarker.setPosition(foundLoc); + if (mainMap && mainMap.getBounds() && !mainMap.getBounds().contains(foundLoc)) { + mainMap.panTo(foundLoc); + } + } + } + } else { + console.log(`Street View lookup failed: ${status}. Throttling retry.`); + } + }); + + return; + } + + distanceCovered += segmentDist; + } + + // If we overshoot, just ensure we throttle + window.lastPanoUpdate = Date.now(); +} + + + +function startCameraAnimation() { + if (idleAnimationId) { + cancelAnimationFrame(idleAnimationId); + } + + let lastStatsTime = 0; + + function animate(timestamp) { + try { + if (!panorama) { + idleAnimationId = requestAnimationFrame(animate); + return; + } + + // Initialize time tracking + if (!lastFrameTime) { + lastFrameTime = timestamp; + } + + const deltaTime = (timestamp - lastFrameTime) / 1000; + lastFrameTime = timestamp; + + cameraTime += deltaTime; + + // Apply visual oscillation + // We use a simplified version of applyCameraMovement logic here directly + + // --- STEP INTERPOLATION --- + // Buffered Playback + // Render the state at (now - delay) + + const renderTime = Date.now() - INTERPOLATION_DELAY; + + // Find the two points bounding renderTime + let p0 = null; + let p1 = null; + + // Iterate backlog to find p0 <= renderTime < p1 + for (let i = 0; i < stateBuffer.length - 1; i++) { + if (stateBuffer[i].t <= renderTime && stateBuffer[i + 1].t > renderTime) { + p0 = stateBuffer[i]; + p1 = stateBuffer[i + 1]; + break; + } + } + + // If we found a span, interpolate + if (p0 && p1) { + const totalTime = p1.t - p0.t; + const elapsed = renderTime - p0.t; + const ratio = elapsed / totalTime; + + // Simple lerp + displayTripSteps = p0.s + (p1.s - p0.s) * ratio; + + } else { + // If we didn't find a span, we are either too old or too new. + if (stateBuffer.length > 0) { + if (renderTime < stateBuffer[0].t) { + // We are waiting for buffer to fill (start of session) + // Just show the first point + displayTripSteps = stateBuffer[0].s; + } else { + // We ran out of future points (buffer underflow / lag) + // Show the latest point + displayTripSteps = stateBuffer[stateBuffer.length - 1].s; + } + } + } + + // Update UI and Position + document.getElementById('currentSteps').textContent = Math.round(displayTripSteps).toLocaleString(); + + // Calculate distance + const distanceTraveled = displayTripSteps * METERS_PER_STEP; + updateTripMeter(distanceTraveled); + + // Throttle Position Updates + const lastDist = window.lastPositionUpdateDistance || 0; + const lastTime = window.lastPanoUpdate || 0; + const now = Date.now(); + + if (Math.abs(distanceTraveled - lastDist) > 10.0 && (now - lastTime) > 1000) { + if (masterPath.length > 0 && panorama) { + updatePositionAlongPath(distanceTraveled); + window.lastPositionUpdateDistance = distanceTraveled; + window.lastPanoUpdate = now; + } + } + // --------------------------- + + // Primary left-right head turn (slow, wide) + const headTurn1 = Math.sin(cameraTime * headingOscillationSpeed) * headingRange * 0.6; + const headTurn2 = Math.sin(cameraTime * headingOscillationSpeed * 2.3) * headingRange * 0.25; + const headTurn3 = Math.sin(cameraTime * headingOscillationSpeed * 0.4) * headingRange * 0.15; + const headingOffset = headTurn1 + headTurn2 + headTurn3; + + // Pitch movements + const pitchOscillation1 = Math.sin(cameraTime * pitchOscillationSpeed * 1.7) * pitchRange * 0.5; + const pitchOscillation2 = Math.sin(cameraTime * pitchOscillationSpeed * 3.1) * pitchRange * 0.3; + const pitchOscillation3 = Math.cos(cameraTime * pitchOscillationSpeed * 0.8) * pitchRange * 0.2; + const finalPitch = basePitch + pitchOscillation1 + pitchOscillation2 + pitchOscillation3; + + const finalHeading = baseHeading + headingOffset; + + if (!isNaN(finalHeading) && !isNaN(finalPitch)) { + panorama.setPov({ + heading: finalHeading, + pitch: finalPitch + }); + } + + idleAnimationId = requestAnimationFrame(animate); + } catch (e) { + console.error("Animation Loop Crash:", e); + // Try to restart or stop? Better to stop to avoid infinite error loops + cancelAnimationFrame(idleAnimationId); + } + } + + idleAnimationId = requestAnimationFrame(animate); +} + +function stopAnimation() { + if (idleAnimationId) { + cancelAnimationFrame(idleAnimationId); + idleAnimationId = null; + } + currentPathIndex = 0; + cameraTime = 0; // Reset camera animation + lastFrameTime = null; // Reset frame time tracking + const animProgress = document.getElementById('animProgress'); + if (animProgress) { + animProgress.textContent = '0%'; + } +} + +function cycleViewMode() { + // Don't cycle if no route is set yet + if (!panorama || !masterPath || masterPath.length === 0) { + console.log('Cannot cycle view mode: no route set'); + return; + } + viewMode = (viewMode + 1) % 3; + updateViewMode(); +} + +function updateViewMode() { + const panoDiv = document.getElementById('panorama'); + const mainMapDiv = document.getElementById('main-map'); + const minimapContainer = document.getElementById('minimap-container'); + + // State machine for transitions + if (viewMode === 0) { + // Switching TO Street View (from Satellite/Map) + // Transition: Animate Main Map shrinking into Minimap + + // Show minimap immediately so we have a target + minimapContainer.classList.remove('hidden-mode'); + + animateMapToMinimap(() => { + panoDiv.classList.remove('hidden-mode'); + + // Fix Black Screen: Trigger resize when pano becomes visible + google.maps.event.trigger(panorama, 'resize'); + + mainMapDiv.classList.add('hidden-mode'); + // Ensure styles are cleaned up + mainMapDiv.style.borderRadius = ''; + + // Fix Satellite Flash: Reset to roadmap so next time we open map it starts clean + if (mainMap) { + mainMap.setMapTypeId(google.maps.MapTypeId.ROADMAP); + } + }); + + } else { + // Switching TO Map or Satellite + + // Initialize if first run + if (!mainMap) { + initializeMainMap(); + // Force layout + google.maps.event.trigger(mainMap, "resize"); + } + + // Set Map Type + const targetType = (viewMode === 1) ? google.maps.MapTypeId.ROADMAP : google.maps.MapTypeId.HYBRID; + mainMap.setMapTypeId(targetType); + + if (mainMapDiv.classList.contains('hidden-mode')) { + // Switching FROM Street View TO Map + // Transition: Animate Main Map expanding from Minimap + + // Ensure Street View is hidden AFTER animation or immediately? + // Better to hide SV immediately so we see map expanding over black/bg? + // Or keep SV visible behind? + // Let's keep SV visible for a moment? No, it might look glitchy. + // Let's hide SV immediately to indicate mode switch, then expand map. + panoDiv.classList.add('hidden-mode'); + + animateMapToFullscreen(() => { + minimapContainer.classList.add('hidden-mode'); + }); + } else { + // Just switching Map <-> Satellite (No animation needed, handled by map type change) + panoDiv.classList.add('hidden-mode'); + mainMapDiv.classList.remove('hidden-mode'); + minimapContainer.classList.add('hidden-mode'); + } + + // Sync Center + if (panorama) { + const currentPos = panorama.getPosition(); + if (currentPos) { + mainMap.setCenter(currentPos); + if (mainMapMarker) mainMapMarker.setPosition(currentPos); + } + } + } +} + +function animateMapToFullscreen(onComplete) { + const mainMapDiv = document.getElementById('main-map'); + const minimapContainer = document.getElementById('minimap-container'); + + // 1. Measure Start State (Minimap) + const rect = minimapContainer.getBoundingClientRect(); + + // 2. Set Initial State on Main Map (Match Minimap) + mainMapDiv.style.transition = 'none'; // Disable transition for setup + mainMapDiv.style.top = rect.top + 'px'; + mainMapDiv.style.left = rect.left + 'px'; + mainMapDiv.style.width = rect.width + 'px'; + mainMapDiv.style.height = rect.height + 'px'; + mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius; + + mainMapDiv.classList.remove('hidden-mode'); + + // 3. Force Reflow + void mainMapDiv.offsetHeight; + + // 4. Set Target State (Fullscreen) + // Re-enable transition defined in CSS + mainMapDiv.style.transition = ''; + + mainMapDiv.style.top = '0'; + mainMapDiv.style.left = '0'; + mainMapDiv.style.width = '100%'; + mainMapDiv.style.height = '100%'; + mainMapDiv.style.borderRadius = '0'; + + // 5. Cleanup on End + const handleTransitionEnd = () => { + mainMapDiv.removeEventListener('transitionend', handleTransitionEnd); + // Clear manual styles so CSS classes take over + mainMapDiv.style.top = ''; + mainMapDiv.style.left = ''; + mainMapDiv.style.width = ''; + mainMapDiv.style.height = ''; + mainMapDiv.style.borderRadius = ''; + google.maps.event.trigger(mainMap, "resize"); + if (onComplete) onComplete(); + }; + mainMapDiv.addEventListener('transitionend', handleTransitionEnd); +} + + + +function animateMapToMinimap(callback) { + // No complex animation for now, just direct switch + callback(); +} + + +function initializeMainMap() { + mainMap = new google.maps.Map(document.getElementById('main-map'), { + center: { lat: 0, lng: 0 }, + zoom: 14, + disableDefaultUI: false, + mapTypeControl: false, + streetViewControl: false, + fullscreenControl: false + }); + + // Sync marker + mainMapMarker = new google.maps.Marker({ + position: { lat: 0, lng: 0 }, + map: mainMap, + icon: { + path: google.maps.SymbolPath.CIRCLE, + scale: 7, + fillColor: '#6366f1', + fillOpacity: 1, + strokeColor: '#ffffff', + strokeWeight: 2, + }, + }); + + if (panorama) { + const pos = panorama.getPosition(); + if (pos) { + mainMap.setCenter(pos); + mainMapMarker.setPosition(pos); + } + } + + // Add polyline if path exists + if (masterPath && masterPath.length > 0) { + mainMapPolyline = new google.maps.Polyline({ + path: masterPath, + geodesic: true, + strokeColor: '#6366f1', + strokeOpacity: 0.8, + strokeWeight: 5 + }); + mainMapPolyline.setMap(mainMap); + } +} + + +// --- Custom Route Logic --- + +function processKML(kmlContent, fileName) { + try { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(kmlContent, "text/xml"); + const placemarks = xmlDoc.getElementsByTagName("Placemark"); + let fullPath = []; + + // Find all LineStrings + for (let i = 0; i < placemarks.length; i++) { + const lineString = placemarks[i].getElementsByTagName("LineString")[0]; + if (lineString) { + const coordsText = lineString.getElementsByTagName("coordinates")[0].textContent.trim(); + const points = parseCoordinates(coordsText); + fullPath = fullPath.concat(points); + } + } + + // Fallback: Check for top-level LineString/LinearRing not in Placemark (rare but possible in fragments) + if (fullPath.length === 0) { + const lineStrings = xmlDoc.getElementsByTagName("LineString"); + for (let i = 0; i < lineStrings.length; i++) { + const coordsText = lineStrings[i].getElementsByTagName("coordinates")[0].textContent.trim(); + const points = parseCoordinates(coordsText); + fullPath = fullPath.concat(points); + } + } + + if (fullPath.length < 2) { + alert("No route found in KML file. Make sure it contains a LineString/Path."); + return; + } + + startFromCustomRoute(fullPath, fileName); + } catch (e) { + console.error("Error parsing KML:", e); + alert("Failed to parse KML file. Please ensure it is a valid Google Maps export."); + } +} + +function parseCoordinates(text) { + // KML format: lon,lat,alt (space separated tuples) + const rawPoints = text.split(/\s+/); + const path = []; + + rawPoints.forEach(pt => { + const parts = pt.split(','); + if (parts.length >= 2) { + // KML is Longitude, Latitude + const lng = parseFloat(parts[0]); + const lat = parseFloat(parts[1]); + if (!isNaN(lat) && !isNaN(lng)) { + path.push({ lat: lat, lng: lng }); + } + } + }); + return path; +} + +function densifyPath(path, maxSegmentLength = 20) { + if (!path || path.length < 2) return path; + + const densified = [path[0]]; + + for (let i = 0; i < path.length - 1; i++) { + const p1 = path[i]; + const p2 = path[i + 1]; + + // We use simple linear interpolation here as we are on a small scale + // For more accuracy we could use spherical interpolation but at 20m linear is fine. + const d = google.maps.geometry.spherical.computeDistanceBetween( + new google.maps.LatLng(p1.lat, p1.lng), + new google.maps.LatLng(p2.lat, p2.lng) + ); + + if (d > maxSegmentLength) { + const numPoints = Math.ceil(d / maxSegmentLength); + for (let j = 1; j < numPoints; j++) { + const ratio = j / numPoints; + densified.push({ + lat: p1.lat + (p2.lat - p1.lat) * ratio, + lng: p1.lng + (p2.lng - p1.lng) * ratio + }); + } + } + densified.push(p2); + } + + return densified; +} + +function startFromCustomRoute(pathData, routeName, isRestoring = false) { + if (!window.google) return; + + // Convert to Google Maps LatLng objects + const densePath = densifyPath(pathData); + masterPath = densePath.map(pt => new google.maps.LatLng(pt.lat, pt.lng)); + + // Calculate total distance + routeTotalDistance = 0; + for (let i = 0; i < masterPath.length - 1; i++) { + routeTotalDistance += google.maps.geometry.spherical.computeDistanceBetween(masterPath[i], masterPath[i + 1]); + } + + const startPoint = masterPath[0]; + + // Save state + const locationData = { + isCustom: true, + customPath: pathData, // Save raw objects {lat, lng} for serialization + routeName: routeName, + startLat: startPoint.lat(), + startLng: startPoint.lng(), + address: routeName, + startAddress: routeName, // Compatibility + endAddress: routeName + }; + + localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData)); + + // Initialize/Reset Server Trip + if (!isRestoring) { + fetch('/api/trip', { method: 'POST' }).catch(console.error); + } + + // Reset display steps + displayTripSteps = 0; + stateBuffer = []; + + // Initialize Map View + startFromLocation(startPoint, routeName); + document.getElementById('setup-overlay').classList.remove('active'); + + // Update UI + document.getElementById('routeInfo').textContent = `${routeName}`; + + // Draw on Minimap + if (minimap) { + if (minimapPolyline) minimapPolyline.setMap(null); + minimapPolyline = new google.maps.Polyline({ + path: masterPath, + geodesic: true, + strokeColor: '#6366f1', + strokeOpacity: 1.0, + strokeWeight: 4 + }); + minimapPolyline.setMap(minimap); + + // Fit bounds + const bounds = new google.maps.LatLngBounds(); + masterPath.forEach(pt => bounds.extend(pt)); + minimap.fitBounds(bounds); + } + + // Draw on Main Map if initialized + if (mainMap) { + if (mainMapPolyline) mainMapPolyline.setMap(null); + mainMapPolyline = new google.maps.Polyline({ + path: masterPath, + geodesic: true, + strokeColor: '#6366f1', + strokeOpacity: 0.8, + strokeWeight: 5 + }); + mainMapPolyline.setMap(mainMap); + } + + // Initial trip meter update + updateTripMeter(0); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7b9ed36 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,200 @@ + + + + + + + Pedestrian Simulator + + + + +
+ + + + +
+
+
+ + +
+
+
+ + +
+
+

🌍 Plan Your Route

+

Enter a start and finish location for your walk

+
+ + +
+
+ + +
+ + +
+ OR +
+ +
+

📂 Use Saved Route

+

Browse and select from your uploaded KML files

+ +
+ +

Note: You'll need a Google Maps API key configured

+
+
+ + +
+
+

🔑 Google Maps API Key Required

+

Enter your Google Maps API key to use Street View

+ + +

+ + How to get an API key → + +

+
+
+ +
+ + +
+
+

🔐 Login Required

+

Please login with your Fitbit account to continue

+ + +
+
+ + +
+
+
+

📂 KML File Browser

+ +
+ +
+ + +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+

No KML files uploaded yet

+
+
+ + +
+
+ + +
+ +
+

No public KML files available

+
+
+
+
+
+ + +
+
+

⚠️ Confirm

+

Are you sure you want to proceed?

+
+ + +
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/privacy.html b/frontend/privacy.html new file mode 100644 index 0000000..806f8e9 --- /dev/null +++ b/frontend/privacy.html @@ -0,0 +1,170 @@ + + + + + + + Privacy Policy - Pedestrian Simulator + + + + + + + +
+ ← Back to App + +
+

Privacy Policy

+

Effective Date: January 11, 2026

+
+ +
+

Introduction

+

Welcome to Pedestrian Simulator. We are committed to protecting your privacy and providing a transparent + experience. This policy explains how we handle your data.

+
+ +
+

Information We Collect

+

To provide the experience of a walking simulator synchronized with your fitness tracker, we collect the + following information from your Fitbit account:

+
    +
  • Profile Information: Your display name and avatar, used to personalize your + interface.
  • +
  • Activity Data: Your daily step counts, which are used to determine your position + along your walking routes.
  • +
  • KML Files: Any route files you choose to upload to the service.
  • +
+
+ +
+

How We Use Your Data

+

Your data is used exclusively to facilitate the core functions of the application:

+
    +
  • Calculating progress along your selected routes.
  • +
  • Saving your trip state so you can resume where you left off.
  • +
  • Displaying your identity in the user menu.
  • +
+
+ +
+

No Third-Party Sharing

+

We do not share, sell, or trade your personal information with any third parties. All + data collected is stored securely on our server and is used ONLY for the operation of Pedestrian + Simulator.

+
+ +
+

KML File Privacy

+

By default, every KML file you upload is private. Only you can see it and use it for + your trips.

+

You have the option to make a file public. This is strictly an opt-in process. When a + file is public:

+
    +
  • Other users can see the route name and your display name.
  • +
  • Other users can use the route for their own trips.
  • +
  • Other users can vote on the quality of the route.
  • +
+

You can toggle a file back to private or delete it at any time.

+
+ +
+

Data Retention

+

We retain your data as long as your account is active. You can delete your uploaded routes at any time. + If you wish to have all your user data removed from our systems, please contact the administrator.

+
+ +
+ © 2026 Pedestrian Simulator. Built with privacy in mind. +
+
+ + + \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..4660b80 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,813 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #6366f1; + --primary-dark: #4f46e5; + --secondary: #8b5cf6; + --success: #10b981; + --bg-dark: #0f172a; + --bg-card: #1e293b; + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --border: #334155; + --shadow: rgba(0, 0, 0, 0.3); +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: linear-gradient(135deg, var(--bg-dark) 0%, #1a1f3a 100%); + color: var(--text-primary); + overflow: hidden; + height: 100vh; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header Styles */ +#header { + background: var(--bg-card); + padding: 0.5rem 1.5rem; + border-bottom: 2px solid var(--border); + box-shadow: 0 4px 6px var(--shadow); + display: flex; + justify-content: space-between; + align-items: center; + gap: 2rem; + height: 70px; +} + +.logo-section h1 { + font-size: 1.25rem; + font-weight: 700; + white-space: nowrap; + background: linear-gradient(135deg, var(--primary), var(--secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0; +} + +#app-footer { + position: absolute; + bottom: 0.5rem; + left: 50%; + transform: translateX(-50%); + font-size: 0.75rem; + color: var(--text-secondary); + opacity: 0.6; + z-index: 10; +} + +#app-footer a { + color: var(--text-secondary); + text-decoration: none; +} + +#app-footer a:hover { + color: var(--primary); + text-decoration: underline; +} + +/* User Menu */ +.user-menu { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(15, 23, 42, 0.4); + padding: 0.5rem 1rem; + border-radius: 12px; + border: 1px solid var(--border); +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid var(--primary); +} + +.user-name { + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary); +} + +#stats-bar { + display: flex; + align-items: center; + background: rgba(15, 23, 42, 0.4); + border-radius: 12px; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border); + flex: 1; + justify-content: flex-end; +} + +.stat-group { + display: flex; + flex-direction: column; + padding: 0.25rem 1rem; + position: relative; + min-width: 100px; +} + +.stat-group.clickable { + cursor: pointer; + background: rgba(99, 102, 241, 0.1); + border-radius: 8px; + transition: all 0.2s ease; + padding-right: 2rem; + flex: 1; + min-width: 200px; +} + +.stat-group.clickable:hover { + background: rgba(99, 102, 241, 0.2); + box-shadow: 0 0 10px rgba(99, 102, 241, 0.1); +} + +.stat-group.clickable:hover .edit-icon { + opacity: 1; + transform: translateY(-50%) scale(1.1); +} + +.stat-label { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.05em; + margin-bottom: 0.1rem; +} + +.stat-value { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.divider { + width: 1px; + height: 30px; + background: var(--border); + margin: 0 0.5rem; +} + +.edit-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 1.2rem; + color: var(--primary); + opacity: 0.5; + transition: all 0.2s ease; +} + +.route-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; + color: var(--primary); +} + +/* Street View Container */ +#streetview-container { + flex: 1; + position: relative; + overflow: hidden; +} + +#panorama { + width: 100%; + height: 100%; +} + +#main-map { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 5; + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); + transform-origin: bottom right; +} + +.hidden-mode { + display: none !important; +} + +/* Minimap */ +#minimap-container { + position: absolute; + bottom: 20px; + right: 20px; + width: 250px; + height: 200px; + z-index: 10; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); + border: 3px solid rgba(255, 255, 255, 0.2); + transition: all 0.3s ease; +} + +#minimap-container:hover { + transform: scale(1.05); + border-color: var(--primary); +} + +#minimap { + width: 100%; + height: 100%; + background: var(--bg-card); +} + +/* Overlay Styles */ +.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(10px); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.overlay.active { + display: flex; +} + +.setup-card { + background: var(--bg-card); + padding: 3rem; + border-radius: 16px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + max-width: 500px; + width: 90%; + border: 1px solid var(--border); +} + +.setup-card h2 { + font-size: 1.75rem; + margin-bottom: 1rem; + background: linear-gradient(135deg, var(--primary), var(--secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.setup-card p { + color: var(--text-secondary); + margin-bottom: 1.5rem; +} + +.setup-card input[type="text"] { + width: 100%; + padding: 1rem; + background: var(--bg-dark); + border: 2px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 1rem; + margin-bottom: 1.5rem; + transition: all 0.3s ease; +} + +.setup-card input[type="text"]:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.input-group { + margin-bottom: 1.5rem; +} + +.input-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-weight: 500; +} + +.input-group input { + width: 100%; + padding: 1rem; + background: var(--bg-dark); + border: 2px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 1rem; + transition: all 0.3s ease; +} + +.input-group input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.primary-btn { + width: 100%; + padding: 1rem 2rem; + background: linear-gradient(135deg, var(--primary), var(--primary-dark)); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.primary-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(99, 102, 241, 0.4); +} + +.primary-btn:active { + transform: translateY(0); +} + +.api-note, +.help-text { + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 1rem; + text-align: center; +} + +.help-text a { + color: var(--primary); + text-decoration: none; +} + +.help-text a:hover { + text-decoration: underline; +} + +.route-text { + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 4px; +} + +.route-text:hover { + color: var(--secondary); +} + +.control-btn { + padding: 0.75rem 1.5rem; + background: var(--bg-dark); + border: 2px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 500; +} + +.control-btn:hover { + border-color: var(--primary); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} + +/* Responsive Design */ +@media (max-width: 768px) { + #header { + flex-direction: column; + height: auto; + padding: 0.5rem; + gap: 0.5rem; + } + + .logo-section h1 { + font-size: 1rem; + } + + #stats-bar { + width: 100%; + overflow-x: auto; + justify-content: flex-start; + } + + .stat-group { + min-width: 80px; + padding: 0.25rem 0.5rem; + } + + .stat-group.clickable { + min-width: 150px; + } + + .setup-card { + padding: 2rem 1.5rem; + } +} + +/* Loading animation */ +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.loading { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Refresh Button Styles */ +.stat-row { + display: flex; + align-items: center; + gap: 8px; +} + +.icon-button { + background: none; + border: none; + color: rgba(255, 255, 255, 0.7); + font-size: 1.2rem; + cursor: pointer; + padding: 0; + transition: all 0.2s ease; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-button:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); + transform: rotate(30deg); +} + +.icon-button:active { + transform: scale(0.9); +} + +.icon-button.spinning { + animation: spin 1s linear infinite; + pointer-events: none; + opacity: 0.5; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* Custom Route UI */ +.separator { + display: flex; + align-items: center; + text-align: center; + margin: 1.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; +} + +.separator::before, +.separator::after { + content: ''; + flex: 1; + border-bottom: 2px solid var(--border); +} + +.separator span { + padding: 0 10px; + background: var(--bg-card); +} + +.custom-route-section { + background: rgba(15, 23, 42, 0.4); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; + text-align: center; +} + +.custom-route-section h3 { + font-size: 1.1rem; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.subtext { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 1rem; +} + + +.secondary-btn { + width: 100%; + padding: 0.75rem 1.5rem; + background: transparent; + border: 2px solid var(--primary); + color: var(--primary); + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-transform: uppercase; +} + +.secondary-btn:hover:not(:disabled) { + background: var(--primary); + color: white; +} + +.secondary-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + border-color: var(--border); + color: var(--text-secondary); +} + +/* KML Browser Styles */ +.kml-browser-card { + background: var(--bg-card); + border-radius: 16px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + max-width: 700px; + width: 90%; + max-height: 80vh; + display: flex; + flex-direction: column; + border: 1px solid var(--border); +} + +.kml-browser-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + border-bottom: 2px solid var(--border); +} + +.kml-browser-header h2 { + font-size: 1.5rem; + background: linear-gradient(135deg, var(--primary), var(--secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0; +} + +.close-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 2rem; + cursor: pointer; + transition: all 0.2s ease; + line-height: 1; +} + +.close-btn:hover { + color: var(--text-primary); + transform: rotate(90deg); +} + +.kml-tabs { + display: flex; + border-bottom: 2px solid var(--border); + padding: 0 2rem; +} + +.kml-tab { + padding: 1rem 2rem; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s ease; + border-bottom: 3px solid transparent; + margin-bottom: -2px; +} + +.kml-tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +.kml-tab:hover { + color: var(--text-primary); +} + +.kml-tab-content { + flex: 1; + overflow-y: auto; + padding: 2rem; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +.upload-section { + margin-bottom: 1.5rem; +} + +.sort-controls { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: rgba(15, 23, 42, 0.4); + border-radius: 8px; +} + +.sort-controls label { + font-weight: 500; + color: var(--text-secondary); +} + +.sort-controls select { + padding: 0.5rem 1rem; + background: var(--bg-dark); + border: 2px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; +} + +.sort-controls select:focus { + outline: none; + border-color: var(--primary); +} + +.kml-file-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.empty-message { + text-align: center; + color: var(--text-secondary); + padding: 3rem 1rem; + font-style: italic; +} + +.kml-file-item { + background: rgba(15, 23, 42, 0.4); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s ease; +} + +.kml-file-item:hover { + border-color: var(--primary); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1); +} + +.kml-file-info { + flex: 1; +} + +.kml-file-name { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.kml-file-meta { + display: flex; + gap: 1rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.kml-file-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.vote-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.5rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 1.2rem; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.vote-btn:hover { + border-color: var(--primary); + color: var(--text-primary); +} + +.vote-btn.active-upvote { + border-color: var(--success); + color: var(--success); + background: rgba(16, 185, 129, 0.1); +} + +.vote-btn.active-downvote { + border-color: #ef4444; + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} + +.vote-count { + font-size: 0.9rem; + font-weight: 600; + min-width: 2rem; + text-align: center; +} + +.action-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.9rem; +} + +.action-btn:hover { + border-color: var(--primary); + color: var(--text-primary); +} + +.action-btn.danger:hover { + border-color: #ef4444; + color: #ef4444; +} +/* Secondary Links (Privacy, etc) */ +.privacy-link-container { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + width: 100%; +} + +.secondary-link { + font-size: 0.85rem; + color: var(--text-secondary); + text-decoration: none; + transition: color 0.2s ease; +} + +.secondary-link:hover { + color: var(--primary); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bbefa6a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.stevenpolley.net/steven/pedestrian-simulator + +go 1.25.5 diff --git a/pedestrian-simulator.code-workspace b/pedestrian-simulator.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/pedestrian-simulator.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/server/context.go b/server/context.go new file mode 100644 index 0000000..01d3b15 --- /dev/null +++ b/server/context.go @@ -0,0 +1,18 @@ +package main + +import "context" + +type contextKey string + +const userIDKey contextKey = "userID" + +// withUserID adds the user ID to the request context +func withUserID(ctx context.Context, userID string) context.Context { + return context.WithValue(ctx, userIDKey, userID) +} + +// getUserID extracts the user ID from the request context +func getUserID(ctx context.Context) (string, bool) { + userID, ok := ctx.Value(userIDKey).(string) + return userID, ok +} diff --git a/server/fitbit.go b/server/fitbit.go new file mode 100644 index 0000000..e735e85 --- /dev/null +++ b/server/fitbit.go @@ -0,0 +1,296 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// Config structure to hold credentials +type FitbitConfig struct { + ClientID string + ClientSecret string + RedirectURI string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var fitbitConfig FitbitConfig + +const ( + scopes = "activity profile" +) + +func InitFitbit() { + fitbitConfig.ClientID = os.Getenv("FITBIT_CLIENT_ID") + fitbitConfig.ClientSecret = os.Getenv("FITBIT_CLIENT_SECRET") + fitbitConfig.RedirectURI = "http://localhost:8080/auth/callback" + + if envRedirect := os.Getenv("FITBIT_AUTH_REDIRECT_URI"); envRedirect != "" { + fitbitConfig.RedirectURI = envRedirect + } +} + +func loadTokens(userID string) (*FitbitConfig, error) { + configPath := fmt.Sprintf("data/users/%s/fitbit_tokens.json", userID) + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config FitbitConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +func saveTokens(userID string, config *FitbitConfig) error { + userDir := fmt.Sprintf("data/users/%s", userID) + if err := os.MkdirAll(userDir, 0755); err != nil { + return fmt.Errorf("error creating user directory: %w", err) + } + + configPath := fmt.Sprintf("%s/fitbit_tokens.json", userDir) + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0600) +} + +// GetDailySteps fetches step count for a specific date (YYYY-MM-DD) for a specific user +func GetDailySteps(userID, date string) (int, error) { + config, err := loadTokens(userID) + if err != nil { + return 0, fmt.Errorf("failed to load tokens: %w", err) + } + + // Check expiry + if time.Now().After(config.ExpiresAt) { + err := refreshTokens(userID, config) + if err != nil { + return 0, err + } + } + + apiURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/date/%s.json", date) + + req, _ := http.NewRequest("GET", apiURL, nil) + req.Header.Set("Authorization", "Bearer "+config.AccessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + return 0, fmt.Errorf("fitbit api error: %s", resp.Status) + } + + var result struct { + Summary struct { + Steps int `json:"steps"` + } `json:"summary"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return 0, err + } + + return result.Summary.Steps, nil +} + +func refreshTokens(userID string, config *FitbitConfig) error { + // POST https://api.fitbit.com/oauth2/token + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", config.RefreshToken) + + req, _ := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode())) + req.SetBasicAuth(fitbitConfig.ClientID, fitbitConfig.ClientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("token refresh failed: %s", resp.Status) + } + + var result struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + + config.AccessToken = result.AccessToken + config.RefreshToken = result.RefreshToken + config.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) + + return saveTokens(userID, config) +} + +// HandleFitbitAuth redirects user to Fitbit authorization page +func HandleFitbitAuth(w http.ResponseWriter, r *http.Request) { + if fitbitConfig.ClientID == "" { + http.Error(w, "FITBIT_CLIENT_ID not set", http.StatusInternalServerError) + return + } + + authURL := fmt.Sprintf( + "https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=%s", + url.QueryEscape(fitbitConfig.ClientID), + url.QueryEscape(fitbitConfig.RedirectURI), + url.QueryEscape(scopes), + ) + + http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) +} + +// HandleFitbitCallback receives the authorization code and exchanges it for tokens +func HandleFitbitCallback(w http.ResponseWriter, r *http.Request) { + fmt.Println("[OAuth Callback] Started") + code := r.URL.Query().Get("code") + if code == "" { + fmt.Println("[OAuth Callback] ERROR: No authorization code") + http.Error(w, "No authorization code received", http.StatusBadRequest) + return + } + fmt.Println("[OAuth Callback] Received authorization code") + + // Exchange code for tokens + fmt.Println("[OAuth Callback] Exchanging code for tokens...") + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", fitbitConfig.RedirectURI) + + req, _ := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode())) + req.SetBasicAuth(fitbitConfig.ClientID, fitbitConfig.ClientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("[OAuth Callback] ERROR: Token exchange request failed: %v\n", err) + http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + fmt.Printf("[OAuth Callback] Token exchange response: %d\n", resp.StatusCode) + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("[OAuth Callback] ERROR: Token exchange failed: %s - %s\n", resp.Status, body) + http.Error(w, fmt.Sprintf("Token exchange failed: %s - %s", resp.Status, body), http.StatusInternalServerError) + return + } + + var tokenResult struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + UserID string `json:"user_id"` // Fitbit returns user_id in token response + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResult); err != nil { + fmt.Printf("[OAuth Callback] ERROR: Failed to parse token response: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to parse token response: %v", err), http.StatusInternalServerError) + return + } + fmt.Printf("[OAuth Callback] Successfully parsed tokens for user: %s\n", tokenResult.UserID) + + fitbitUserID := tokenResult.UserID + if fitbitUserID == "" { + fmt.Println("[OAuth Callback] ERROR: No user_id in token response") + http.Error(w, "No user_id in token response", http.StatusInternalServerError) + return + } + + // Fetch user profile from Fitbit + fmt.Println("[OAuth Callback] Fetching user profile from Fitbit...") + userID, displayName, avatarURL, err := FetchFitbitUserProfile(tokenResult.AccessToken) + if err != nil { + fmt.Printf("[OAuth Callback] WARNING: Could not fetch user profile: %v\n", err) + // Use user_id from token if profile fetch fails + displayName = fitbitUserID + avatarURL = "" + } else { + fmt.Printf("[OAuth Callback] Successfully fetched profile for: %s\n", displayName) + // Verify user_id matches + if userID != fitbitUserID { + fmt.Printf("[OAuth Callback] WARNING: user_id mismatch: token=%s, profile=%s\n", fitbitUserID, userID) + } + } + + // Create or update user in registry + fmt.Println("[OAuth Callback] Creating/updating user in registry...") + if userRegistry == nil { + fmt.Println("[OAuth Callback] ERROR: userRegistry is nil!") + http.Error(w, "Server configuration error: user registry not initialized", http.StatusInternalServerError) + return + } + _, err = userRegistry.CreateOrUpdateUser(fitbitUserID, displayName, avatarURL) + if err != nil { + fmt.Printf("[OAuth Callback] ERROR: Failed to create user: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to create user: %v", err), http.StatusInternalServerError) + return + } + fmt.Println("[OAuth Callback] User created/updated successfully") + + // Save Fitbit tokens + fmt.Println("[OAuth Callback] Saving Fitbit tokens...") + userConfig := &FitbitConfig{ + ClientID: fitbitConfig.ClientID, + ClientSecret: fitbitConfig.ClientSecret, + RedirectURI: fitbitConfig.RedirectURI, + AccessToken: tokenResult.AccessToken, + RefreshToken: tokenResult.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tokenResult.ExpiresIn) * time.Second), + } + if err := saveTokens(fitbitUserID, userConfig); err != nil { + fmt.Printf("[OAuth Callback] ERROR: Failed to save tokens: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to save tokens: %v", err), http.StatusInternalServerError) + return + } + + fmt.Println("[OAuth Callback] Tokens saved successfully") + + // Create session + fmt.Println("[OAuth Callback] Creating session...") + session, err := CreateSession(fitbitUserID) + if err != nil { + fmt.Printf("[OAuth Callback] ERROR: Failed to create session: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to create session: %v", err), http.StatusInternalServerError) + return + } + fmt.Println("[OAuth Callback] Session created successfully") + + // Set session cookie + fmt.Println("[OAuth Callback] Setting session cookie...") + SetSessionCookie(w, session) + + fmt.Printf("[OAuth Callback] ✅ SUCCESS! User %s (%s) logged in\n", displayName, fitbitUserID) + + // Redirect to homepage + fmt.Println("[OAuth Callback] Redirecting to homepage") + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) +} diff --git a/server/kml.go b/server/kml.go new file mode 100644 index 0000000..1aa21bb --- /dev/null +++ b/server/kml.go @@ -0,0 +1,595 @@ +package main + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "math" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// KML structure for parsing +type KML struct { + XMLName xml.Name `xml:"kml"` + Document KMLDocument `xml:"Document"` +} + +type KMLDocument struct { + Placemarks []KMLPlacemark `xml:"Placemark"` +} + +type KMLPlacemark struct { + LineString KMLLineString `xml:"LineString"` +} + +type KMLLineString struct { + Coordinates string `xml:"coordinates"` +} + +// KML Metadata +type KMLMetadata struct { + Filename string `json:"filename"` + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` // User's display name + Distance float64 `json:"distance"` // in kilometers + IsPublic bool `json:"is_public"` + UploadedAt time.Time `json:"uploaded_at"` + Votes int `json:"votes"` // Net votes (calculated from voting system) +} + +// Global vote tracking: kmlID -> userID -> vote (+1, -1, or 0) +type VoteRegistry struct { + Votes map[string]map[string]int `json:"votes"` // kmlID -> (userID -> vote) + mu sync.RWMutex +} + +var voteRegistry *VoteRegistry + +// InitVoteRegistry loads the vote registry from disk +func InitVoteRegistry() { + voteRegistry = &VoteRegistry{ + Votes: make(map[string]map[string]int), + } + voteRegistry.Load() +} + +func (vr *VoteRegistry) Load() error { + vr.mu.Lock() + defer vr.mu.Unlock() + + data, err := os.ReadFile("data/kml_votes.json") + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + return json.Unmarshal(data, vr) +} + +func (vr *VoteRegistry) Save() error { + vr.mu.RLock() + defer vr.mu.RUnlock() + return vr.saveUnlocked() +} + +func (vr *VoteRegistry) saveUnlocked() error { + if err := os.MkdirAll("data", 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(vr, "", " ") + if err != nil { + return err + } + + return os.WriteFile("data/kml_votes.json", data, 0644) +} + +// GetVote return the vote of a user for a KML file (-1, 0, +1) +func (vr *VoteRegistry) GetVote(kmlID, userID string) int { + vr.mu.RLock() + defer vr.mu.RUnlock() + + if userVotes, exists := vr.Votes[kmlID]; exists { + return userVotes[userID] + } + return 0 +} + +// SetVote sets a user's vote for a KML file +func (vr *VoteRegistry) SetVote(kmlID, userID string, vote int) error { + vr.mu.Lock() + defer vr.mu.Unlock() + + if vr.Votes[kmlID] == nil { + vr.Votes[kmlID] = make(map[string]int) + } + + if vote == 0 { + delete(vr.Votes[kmlID], userID) + } else { + vr.Votes[kmlID][userID] = vote + } + + return vr.saveUnlocked() +} + +// CalculateNetVotes calculates net votes for a KML file +func (vr *VoteRegistry) CalculateNetVotes(kmlID string) int { + vr.mu.RLock() + defer vr.mu.RUnlock() + + total := 0 + if userVotes, exists := vr.Votes[kmlID]; exists { + for _, vote := range userVotes { + total += vote + } + } + return total +} + +// Haversine formula to calculate distance between two lat/lng points +func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadiusKm = 6371.0 + + dLat := (lat2 - lat1) * math.Pi / 180.0 + dLon := (lon2 - lon1) * math.Pi / 180.0 + + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(lat1*math.Pi/180.0)*math.Cos(lat2*math.Pi/180.0)* + math.Sin(dLon/2)*math.Sin(dLon/2) + + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return earthRadiusKm * c +} + +// ParseKMLDistance parses a KML file and calculates the total distance +func ParseKMLDistance(kmlData []byte) (float64, error) { + decoder := xml.NewDecoder(bytes.NewReader(kmlData)) + totalDistance := 0.0 + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return 0, err + } + + switch se := token.(type) { + case xml.StartElement: + // We only care about coordinates within a LineString context. + // However, since Points have only one coordinate and result in 0 anyway, + // searching for any tag is both easier and robust enough. + if se.Name.Local == "coordinates" { + var coords string + if err := decoder.DecodeElement(&coords, &se); err != nil { + continue + } + + // Sum up distance from this LineString + items := strings.Fields(coords) + var prevLat, prevLon float64 + first := true + + for _, item := range items { + parts := strings.Split(strings.TrimSpace(item), ",") + if len(parts) < 2 { + continue + } + + var lon, lat float64 + _, err1 := fmt.Sscanf(parts[0], "%f", &lon) + _, err2 := fmt.Sscanf(parts[1], "%f", &lat) + if err1 != nil || err2 != nil { + continue + } + + if !first { + totalDistance += haversineDistance(prevLat, prevLon, lat, lon) + } + + prevLat = lat + prevLon = lon + first = false + } + } + } + } + + return totalDistance, nil +} + +// HandleKMLUpload handles KML file uploads +func HandleKMLUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, ok := getUserID(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse multipart form + if err := r.ParseMultipartForm(10 << 20); err != nil { // 10 MB limit + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + + file, handler, err := r.FormFile("kml") + if err != nil { + http.Error(w, "No file uploaded", http.StatusBadRequest) + return + } + defer file.Close() + + // Read file data + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Failed to read file", http.StatusInternalServerError) + return + } + + // Calculate distance + distance, err := ParseKMLDistance(data) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to parse KML: %v", err), http.StatusBadRequest) + return + } + + // Save KML file + kmlDir := fmt.Sprintf("data/users/%s/kml", userID) + if err := os.MkdirAll(kmlDir, 0755); err != nil { + http.Error(w, "Failed to create directory", http.StatusInternalServerError) + return + } + + kmlPath := filepath.Join(kmlDir, handler.Filename) + if err := os.WriteFile(kmlPath, data, 0644); err != nil { + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } + + // Get user info + user, _ := userRegistry.GetUser(userID) + displayName := userID + if user != nil { + displayName = user.DisplayName + } + + // Create metadata + metadata := KMLMetadata{ + Filename: handler.Filename, + UserID: userID, + DisplayName: displayName, + Distance: distance, + IsPublic: false, // Private by default + UploadedAt: time.Now(), + Votes: 0, + } + + metaPath := filepath.Join(kmlDir, handler.Filename+".meta.json") + metaData, _ := json.MarshalIndent(metadata, "", " ") + if err := os.WriteFile(metaPath, metaData, 0644); err != nil { + http.Error(w, "Failed to save metadata", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "filename": handler.Filename, + "distance": distance, + }) +} + +// HandleKMLList lists KML files (user's own + public files) +func HandleKMLList(w http.ResponseWriter, r *http.Request) { + userID, ok := getUserID(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var allFiles []KMLMetadata + + // Walk through all user directories + usersDir := "data/users" + entries, err := os.ReadDir(usersDir) + if err != nil { + // No users yet + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "my_files": []KMLMetadata{}, + "public_files": []KMLMetadata{}, + }) + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + ownerID := entry.Name() + kmlDir := filepath.Join(usersDir, ownerID, "kml") + + kmlFiles, err := os.ReadDir(kmlDir) + if err != nil { + continue + } + + for _, kmlFile := range kmlFiles { + if !strings.HasSuffix(kmlFile.Name(), ".meta.json") { + continue + } + + metaPath := filepath.Join(kmlDir, kmlFile.Name()) + data, err := os.ReadFile(metaPath) + if err != nil { + continue + } + + var meta KMLMetadata + if err := json.Unmarshal(data, &meta); err != nil { + continue + } + + // Calculate current votes + kmlID := fmt.Sprintf("%s/%s", ownerID, meta.Filename) + meta.Votes = voteRegistry.CalculateNetVotes(kmlID) + + // Include if: 1) owned by current user, OR 2) public + if ownerID == userID || meta.IsPublic { + allFiles = append(allFiles, meta) + } + } + } + + // Separate into own files and public files + var myFiles, publicFiles []KMLMetadata + for _, file := range allFiles { + if file.UserID == userID { + myFiles = append(myFiles, file) + } + if file.IsPublic { + publicFiles = append(publicFiles, file) + } + } + + // Sort public files by votes (highest first) + sort.Slice(publicFiles, func(i, j int) bool { + return publicFiles[i].Votes > publicFiles[j].Votes + }) + + // Sort my files by upload date (newest first) + sort.Slice(myFiles, func(i, j int) bool { + return myFiles[i].UploadedAt.After(myFiles[j].UploadedAt) + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "my_files": myFiles, + "public_files": publicFiles, + }) +} + +// HandleKMLPrivacyToggle toggles the privacy setting of a KML file +func HandleKMLPrivacyToggle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, ok := getUserID(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + Filename string `json:"filename"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", userID, req.Filename) + data, err := os.ReadFile(metaPath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + var meta KMLMetadata + if err := json.Unmarshal(data, &meta); err != nil { + http.Error(w, "Failed to parse metadata", http.StatusInternalServerError) + return + } + + // Verify ownership + if meta.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Toggle privacy + meta.IsPublic = !meta.IsPublic + + // Save updated metadata + newData, _ := json.MarshalIndent(meta, "", " ") + if err := os.WriteFile(metaPath, newData, 0644); err != nil { + http.Error(w, "Failed to update metadata", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "is_public": meta.IsPublic, + }) +} + +// HandleKMLVote handles voting on KML files (toggle upvote/downvote/none) +func HandleKMLVote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, ok := getUserID(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + OwnerID string `json:"owner_id"` + Filename string `json:"filename"` + Vote int `json:"vote"` // +1, -1, or 0 to remove vote + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Validate vote value + if req.Vote != -1 && req.Vote != 0 && req.Vote != 1 { + http.Error(w, "Invalid vote value (must be -1, 0, or 1)", http.StatusBadRequest) + return + } + + kmlID := fmt.Sprintf("%s/%s", req.OwnerID, req.Filename) + + // Set the vote + if err := voteRegistry.SetVote(kmlID, userID, req.Vote); err != nil { + http.Error(w, "Failed to save vote", http.StatusInternalServerError) + return + } + + // Calculate new net votes + netVotes := voteRegistry.CalculateNetVotes(kmlID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "net_votes": netVotes, + }) +} + +// HandleKMLDelete deletes a KML file with ownership verification +func HandleKMLDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, ok := getUserID(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + Filename string `json:"filename"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Verify ownership by reading metadata + metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", userID, req.Filename) + data, err := os.ReadFile(metaPath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + var meta KMLMetadata + if err := json.Unmarshal(data, &meta); err != nil { + http.Error(w, "Failed to parse metadata", http.StatusInternalServerError) + return + } + + // Verify ownership + if meta.UserID != userID { + http.Error(w, "Forbidden - you can only delete your own files", http.StatusForbidden) + return + } + + // Delete KML file and metadata + kmlPath := fmt.Sprintf("data/users/%s/kml/%s", userID, req.Filename) + os.Remove(kmlPath) + os.Remove(metaPath) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + }) +} + +// HandleKMLDownload serves a KML file for downloading/viewing +func HandleKMLDownload(w http.ResponseWriter, r *http.Request) { + userID, ok := getUserID(r.Context()) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + ownerID := r.URL.Query().Get("owner_id") + filename := r.URL.Query().Get("filename") + + if ownerID == "" || filename == "" { + http.Error(w, "Missing owner_id or filename", http.StatusBadRequest) + return + } + + // Verify permission: ownerID == userID OR file is public + if ownerID != userID { + metaPath := fmt.Sprintf("data/users/%s/kml/%s.meta.json", ownerID, filename) + data, err := os.ReadFile(metaPath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + var meta KMLMetadata + if err := json.Unmarshal(data, &meta); err != nil { + http.Error(w, "Error reading metadata", http.StatusInternalServerError) + return + } + + if !meta.IsPublic { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + + kmlPath := fmt.Sprintf("data/users/%s/kml/%s", ownerID, filename) + data, err := os.ReadFile(kmlPath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/vnd.google-earth.kml+xml") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + w.Write(data) +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..c426cb6 --- /dev/null +++ b/server/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" +) + +var ( + stepManagers = make(map[string]*StepManager) // userID -> StepManager + smMutex sync.RWMutex +) + +// getOrCreateStepManager retrieves or creates a StepManager for the given user +func getOrCreateStepManager(userID string) *StepManager { + smMutex.RLock() + sm, exists := stepManagers[userID] + smMutex.RUnlock() + + if exists { + return sm + } + + // Create new StepManager for this user + smMutex.Lock() + defer smMutex.Unlock() + + // Double-check it wasn't created while we were waiting for the lock + if sm, exists := stepManagers[userID]; exists { + return sm + } + + sm = NewStepManager(userID) + stepManagers[userID] = sm + return sm +} + +func main() { + // Initialize components + InitFitbit() + InitUserRegistry() + InitVoteRegistry() + + // 1. Serve Static Files (Frontend) + fs := http.FileServer(http.Dir("frontend")) + http.Handle("/", fs) + + // 2. API Endpoints (all require authentication) + http.HandleFunc("/api/status", RequireAuth(func(w http.ResponseWriter, r *http.Request) { + userID, _ := getUserID(r.Context()) + sm := getOrCreateStepManager(userID) + + status := sm.GetStatus() + + // Add user info to status + user, exists := userRegistry.GetUser(userID) + if exists && user != nil { + status["user"] = map[string]string{ + "displayName": user.DisplayName, + "avatarUrl": user.AvatarURL, + } + } else { + fmt.Printf("[API Status] WARNING: User info not found for ID: %s (exists=%v)\n", userID, exists) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) + })) + + http.HandleFunc("/api/refresh", RequireAuth(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, _ := getUserID(r.Context()) + sm := getOrCreateStepManager(userID) + sm.Sync() + w.WriteHeader(http.StatusOK) + })) + + http.HandleFunc("/api/trip", RequireAuth(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, _ := getUserID(r.Context()) + sm := getOrCreateStepManager(userID) + sm.StartNewTrip() + w.WriteHeader(http.StatusOK) + })) + + http.HandleFunc("/api/drain", RequireAuth(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, _ := getUserID(r.Context()) + sm := getOrCreateStepManager(userID) + go sm.Drain() // Async so we don't block + w.WriteHeader(http.StatusOK) + })) + + // 3. KML Management Endpoints + http.HandleFunc("/api/kml/upload", RequireAuth(HandleKMLUpload)) + http.HandleFunc("/api/kml/list", RequireAuth(HandleKMLList)) + http.HandleFunc("/api/kml/privacy", RequireAuth(HandleKMLPrivacyToggle)) + http.HandleFunc("/api/kml/vote", RequireAuth(HandleKMLVote)) + http.HandleFunc("/api/kml/delete", RequireAuth(HandleKMLDelete)) + http.HandleFunc("/api/kml/download", RequireAuth(HandleKMLDownload)) + + // 4. Fitbit OAuth Endpoints + http.HandleFunc("/auth/fitbit", HandleFitbitAuth) + http.HandleFunc("/auth/callback", HandleFitbitCallback) + + // 5. Logout Endpoint + http.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) { + session, err := GetSessionFromRequest(r) + if err == nil { + DeleteSession(session.Token) + } + ClearSessionCookie(w) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + }) + + // 6. Start Server + binding := "0.0.0.0:8080" + fmt.Printf("Server starting on http://%s\n", binding) + log.Fatal(http.ListenAndServe(binding, nil)) +} diff --git a/server/session.go b/server/session.go new file mode 100644 index 0000000..74ebcb4 --- /dev/null +++ b/server/session.go @@ -0,0 +1,159 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +const ( + sessionCookieName = "pedestrian_simulator_session" + sessionDuration = 30 * 24 * time.Hour // 30 days +) + +type Session struct { + Token string `json:"token"` + FitbitUserID string `json:"fitbit_user_id"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// GenerateSessionToken creates a cryptographically secure random token +func GenerateSessionToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// CreateSession generates a new session for a user +func CreateSession(fitbitUserID string) (*Session, error) { + token, err := GenerateSessionToken() + if err != nil { + return nil, err + } + + now := time.Now() + session := &Session{ + Token: token, + FitbitUserID: fitbitUserID, + CreatedAt: now, + ExpiresAt: now.Add(sessionDuration), + } + + if err := SaveSession(session); err != nil { + return nil, err + } + + return session, nil +} + +// SaveSession persists a session to disk +func SaveSession(session *Session) error { + sessionDir := "data/sessions" + if err := os.MkdirAll(sessionDir, 0755); err != nil { + return fmt.Errorf("failed to create sessions directory: %w", err) + } + + sessionPath := filepath.Join(sessionDir, session.Token+".json") + data, err := json.MarshalIndent(session, "", " ") + if err != nil { + return err + } + + return os.WriteFile(sessionPath, data, 0600) +} + +// LoadSession loads a session from disk by token +func LoadSession(token string) (*Session, error) { + sessionPath := filepath.Join("data/sessions", token+".json") + data, err := os.ReadFile(sessionPath) + if err != nil { + return nil, err + } + + var session Session + if err := json.Unmarshal(data, &session); err != nil { + return nil, err + } + + // Check if expired + if time.Now().After(session.ExpiresAt) { + DeleteSession(token) + return nil, fmt.Errorf("session expired") + } + + return &session, nil +} + +// DeleteSession removes a session from disk +func DeleteSession(token string) error { + sessionPath := filepath.Join("data/sessions", token+".json") + return os.Remove(sessionPath) +} + +// GetSessionFromRequest extracts the session from the request cookie +func GetSessionFromRequest(r *http.Request) (*Session, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return nil, err + } + + return LoadSession(cookie.Value) +} + +// SetSessionCookie sets the session cookie on the response +func SetSessionCookie(w http.ResponseWriter, session *Session) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: session.Token, + Path: "/", + Expires: session.ExpiresAt, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + // Secure: true, // Enable in production with HTTPS + }) +} + +// ClearSessionCookie removes the session cookie +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) +} + +// RequireAuth is middleware that ensures the user is authenticated +func RequireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session, err := GetSessionFromRequest(r) + if err != nil { + // Not authenticated - for API calls return 401, for pages redirect + if isAPIRequest(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + } else { + http.Redirect(w, r, "/auth/fitbit", http.StatusTemporaryRedirect) + } + return + } + + // Store user ID in request context for handlers to use + ctx := r.Context() + ctx = withUserID(ctx, session.FitbitUserID) + next(w, r.WithContext(ctx)) + } +} + +// isAPIRequest checks if the request is to an API endpoint +func isAPIRequest(r *http.Request) bool { + return len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" +} diff --git a/server/step_manager.go b/server/step_manager.go new file mode 100644 index 0000000..0224a99 --- /dev/null +++ b/server/step_manager.go @@ -0,0 +1,269 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +type TripState struct { + StartDate string `json:"start_date"` // YYYY-MM-DD + StartTime time.Time `json:"start_time"` // Exact start time + StartDayInitialSteps int `json:"start_day_initial_steps"` // Steps on the tracker when trip started + DailyCache map[string]int `json:"daily_cache"` // Cache of steps for past days +} + +type StepManager struct { + mu sync.Mutex + userID string // Fitbit user ID + tripState TripState + + // Smoothing State + previousTotalSteps int // What we last told the client (or where we started smoothing from) + targetTotalSteps int // The actual total steps we just fetched/calculated + lastSyncTime time.Time + nextSyncTime time.Time + syncInterval time.Duration +} + +func NewStepManager(userID string) *StepManager { + now := time.Now() + interval := 15 * time.Minute + + // Default state (will be used if load fails or file missing) + defaultState := TripState{ + StartDate: now.Format("2006-01-02"), + StartTime: now, + DailyCache: make(map[string]int), + } + + sm := &StepManager{ + userID: userID, + tripState: defaultState, + syncInterval: interval, + lastSyncTime: now.Add(-interval), + nextSyncTime: now, + } + + if err := sm.LoadTripState(); err != nil { + fmt.Printf("Warning: Failed to load trip state: %v. Using new trip defaults.\n", err) + } else { + // Initialize total steps from the loaded state to avoid interpolating from 0 + initialTotal := sm.RecalculateTotalFromState() + sm.previousTotalSteps = initialTotal + sm.targetTotalSteps = initialTotal + fmt.Printf("Initialized step counts from cache: %d\n", initialTotal) + } + + return sm +} + +func (sm *StepManager) LoadTripState() error { + tripPath := fmt.Sprintf("data/users/%s/trip.json", sm.userID) + data, err := os.ReadFile(tripPath) + if err != nil { + if os.IsNotExist(err) { + return nil // Normal for first run + } + return err + } + + var loadedState TripState + if err := json.Unmarshal(data, &loadedState); err != nil { + return fmt.Errorf("failed to parse trip.json: %w", err) + } + + // Only update if valid + sm.tripState = loadedState + if sm.tripState.DailyCache == nil { + sm.tripState.DailyCache = make(map[string]int) + } + fmt.Printf("Loaded trip state: StartDate=%s, InitialSteps=%d\n", sm.tripState.StartDate, sm.tripState.StartDayInitialSteps) + return nil +} + +func (sm *StepManager) SaveTripState() { + userDir := fmt.Sprintf("data/users/%s", sm.userID) + if err := os.MkdirAll(userDir, 0755); err != nil { + fmt.Printf("Error creating user directory: %v\n", err) + return + } + tripPath := fmt.Sprintf("%s/trip.json", userDir) + data, _ := json.MarshalIndent(sm.tripState, "", " ") + os.WriteFile(tripPath, data, 0644) +} + +func (sm *StepManager) StartNewTrip() { + sm.mu.Lock() + defer sm.mu.Unlock() + now := time.Now() + initialSteps, err := GetDailySteps(sm.userID, now.Format("2006-01-02")) + if err != nil { + initialSteps = 0 + fmt.Printf("Error fetching initial steps: %v\n", err) + } + sm.tripState = TripState{ + StartDate: now.Format("2006-01-02"), + StartTime: now, + StartDayInitialSteps: initialSteps, + DailyCache: make(map[string]int), + } + // On new trip, previous total is 0 + sm.previousTotalSteps = 0 + sm.targetTotalSteps = 0 + sm.SaveTripState() + + // Trigger immediate sync to set baseline + go sm.Sync() +} + +// Sync fetches data for all days in the trip using the default interval +func (sm *StepManager) Sync() { + sm.performSync(sm.syncInterval) +} + +// Drain fetches data and sets a short sync interval to fast-forward interpolation +func (sm *StepManager) Drain() { + sm.performSync(30 * time.Second) +} + +// performSync implementation +func (sm *StepManager) performSync(interval time.Duration) { + sm.mu.Lock() + tripStateCopy := sm.tripState + // Deep copy the map to avoid data races during async network calls + newDailyCache := make(map[string]int) + for k, v := range tripStateCopy.DailyCache { + newDailyCache[k] = v + } + sm.mu.Unlock() + + totalSteps := 0 + today := time.Now().Format("2006-01-02") + + // Parse start date + start, _ := time.Parse("2006-01-02", tripStateCopy.StartDate) + end, _ := time.Parse("2006-01-02", today) + + // Iterate from Start Date to Today + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + dateStr := d.Format("2006-01-02") + + var steps int + var err error + + // Check cache first for past days + // For today, we always fetch. + // Ideally we might trust cache for past days, but re-checking isn't bad if we want to catch up. + // The current logic trusts cache for past days. + shouldFetch := (dateStr == today) + if !shouldFetch { + if cached, ok := newDailyCache[dateStr]; ok { + steps = cached + } else { + shouldFetch = true + } + } + + if shouldFetch { + steps, err = GetDailySteps(sm.userID, dateStr) + if err != nil { + fmt.Printf("Error fetching steps for %s: %v\n", dateStr, err) + return // Don't proceed with sync if fetch fails + } + // Update the local cache + newDailyCache[dateStr] = steps + } + + // Calculate contribution to total + if dateStr == tripStateCopy.StartDate { + // Substract the steps that were already there when we started + contribution := steps - tripStateCopy.StartDayInitialSteps + if contribution < 0 { + contribution = 0 + } + totalSteps += contribution + } else { + totalSteps += steps + } + } + + sm.mu.Lock() + defer sm.mu.Unlock() + + // Update State + sm.tripState.DailyCache = newDailyCache + sm.SaveTripState() + + // Update Smoothing Targets + sm.previousTotalSteps = sm.calculateSmoothedTokenAt(time.Now()) // Snapshot current interpolated value as new start + sm.targetTotalSteps = totalSteps + + sm.lastSyncTime = time.Now() + sm.nextSyncTime = time.Now().Add(interval) + + fmt.Printf("Sync Complete. Total Trip Steps: %d\n", sm.targetTotalSteps) +} + +// calculateSmoothedTokenAt returns the interpolated step count at a given time +func (sm *StepManager) calculateSmoothedTokenAt(t time.Time) int { + totalDuration := sm.nextSyncTime.Sub(sm.lastSyncTime) + elapsed := t.Sub(sm.lastSyncTime) + + if totalDuration <= 0 { + return sm.targetTotalSteps + } + + progress := float64(elapsed) / float64(totalDuration) + if progress < 0 { + progress = 0 + } + if progress > 1 { + progress = 1 + } + + // Linear interpolation from Previous -> Target + delta := sm.targetTotalSteps - sm.previousTotalSteps + return sm.previousTotalSteps + int(float64(delta)*progress) +} + +func (sm *StepManager) GetStatus() map[string]interface{} { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Auto-trigger sync if needed + if time.Now().After(sm.nextSyncTime) { + go sm.Sync() // Async sync + } + + currentSmoothed := sm.calculateSmoothedTokenAt(time.Now()) + + return map[string]interface{}{ + "tripSteps": currentSmoothed, + "nextSyncTime": sm.nextSyncTime.UnixMilli(), + } +} + +// RecalculateTotalFromState sums up the steps from the DailyCache without making external API calls +func (sm *StepManager) RecalculateTotalFromState() int { + total := 0 + for dateStr, steps := range sm.tripState.DailyCache { + // YYYY-MM-DD string comparison works for chronological order + if dateStr < sm.tripState.StartDate { + continue + } + + if dateStr == sm.tripState.StartDate { + contribution := steps - sm.tripState.StartDayInitialSteps + if contribution < 0 { + contribution = 0 + } + total += contribution + } else { + total += steps + } + } + return total +} diff --git a/server/user.go b/server/user.go new file mode 100644 index 0000000..dc87922 --- /dev/null +++ b/server/user.go @@ -0,0 +1,156 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "sync" + "time" +) + +type User struct { + FitbitUserID string `json:"fitbit_user_id"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` +} + +type UserRegistry struct { + Users map[string]*User `json:"users"` // Map of FitbitUserID -> User + mu sync.RWMutex +} + +var userRegistry *UserRegistry + +// InitUserRegistry loads or creates the user registry +func InitUserRegistry() { + userRegistry = &UserRegistry{ + Users: make(map[string]*User), + } + userRegistry.Load() +} + +// Load reads the user registry from disk +func (ur *UserRegistry) Load() error { + ur.mu.Lock() + defer ur.mu.Unlock() + + data, err := os.ReadFile("data/users.json") + if err != nil { + if os.IsNotExist(err) { + return nil // First run, no users yet + } + return err + } + + return json.Unmarshal(data, ur) +} + +// Save writes the user registry to disk +func (ur *UserRegistry) Save() error { + ur.mu.RLock() + defer ur.mu.RUnlock() + return ur.saveUnlocked() +} + +// saveUnlocked writes the user registry to disk without locking (caller must hold lock) +func (ur *UserRegistry) saveUnlocked() error { + if err := os.MkdirAll("data", 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(ur, "", " ") + if err != nil { + return err + } + + return os.WriteFile("data/users.json", data, 0644) +} + +// GetUser retrieves a user by Fitbit user ID +func (ur *UserRegistry) GetUser(fitbitUserID string) (*User, bool) { + ur.mu.RLock() + defer ur.mu.RUnlock() + + user, exists := ur.Users[fitbitUserID] + return user, exists +} + +// CreateOrUpdateUser adds or updates a user in the registry +func (ur *UserRegistry) CreateOrUpdateUser(fitbitUserID, displayName, avatarURL string) (*User, error) { + ur.mu.Lock() + defer ur.mu.Unlock() + + user, exists := ur.Users[fitbitUserID] + if exists { + // Update existing user + user.DisplayName = displayName + user.AvatarURL = avatarURL + } else { + // Create new user + user = &User{ + FitbitUserID: fitbitUserID, + DisplayName: displayName, + AvatarURL: avatarURL, + CreatedAt: time.Now(), + } + ur.Users[fitbitUserID] = user + } + + // Save without locking (we already have the lock) + if err := ur.saveUnlocked(); err != nil { + return nil, err + } + + // Create user directory + userDir := fmt.Sprintf("data/users/%s", fitbitUserID) + if err := os.MkdirAll(userDir, 0755); err != nil { + return nil, err + } + kmlDir := fmt.Sprintf("data/users/%s/kml", fitbitUserID) + if err := os.MkdirAll(kmlDir, 0755); err != nil { + return nil, err + } + + return user, nil +} + +// FetchFitbitUserProfile fetches the user's profile from Fitbit API +func FetchFitbitUserProfile(accessToken string) (userID, displayName, avatarURL string, err error) { + apiURL := "https://api.fitbit.com/1/user/-/profile.json" + + req, _ := http.NewRequest("GET", apiURL, nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", "", "", fmt.Errorf("fitbit profile api error: %s", resp.Status) + } + + var result struct { + User struct { + EncodedID string `json:"encodedId"` + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + Avatar150 string `json:"avatar150"` + } `json:"user"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", "", "", err + } + + avatar := result.User.Avatar150 + if avatar == "" { + avatar = result.User.Avatar + } + + return result.User.EncodedID, result.User.DisplayName, avatar, nil +}