This commit is contained in:
46
.gitea/workflows/pedestrian-simulator.yaml
Normal file
46
.gitea/workflows/pedestrian-simulator.yaml
Normal file
@@ -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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
env.sh
|
||||||
|
*.exe
|
||||||
|
*.json
|
||||||
|
*.kml
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -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" ]
|
||||||
92
README.md
92
README.md
@@ -1,5 +1,91 @@
|
|||||||
# pedestrian-simulator
|
# Pedestrian Simulator - Google Street View Walking 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.
|
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.
|
||||||
|
|
||||||
Be outside while you are inside. The future is now!
|
## 🌍 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.*
|
||||||
|
|||||||
17
data/README.md
Normal file
17
data/README.md
Normal file
@@ -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.
|
||||||
1517
frontend/app.js
Normal file
1517
frontend/app.js
Normal file
File diff suppressed because it is too large
Load Diff
200
frontend/index.html
Normal file
200
frontend/index.html
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pedestrian Simulator</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Header with stats -->
|
||||||
|
<div id="header">
|
||||||
|
<div class="logo-section">
|
||||||
|
<h1>步行 Pedestrian Simulator</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
<div id="user-menu" class="user-menu" style="display: none;">
|
||||||
|
<img id="userAvatar" class="user-avatar" src="" alt="User" />
|
||||||
|
<span id="userName" class="user-name"></span>
|
||||||
|
<button id="kmlBrowserButton" class="icon-button" title="Browse KML Files">📂</button>
|
||||||
|
<button id="logoutButton" class="icon-button" title="Logout">🚪</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats-bar">
|
||||||
|
<!-- Unified Status Bar -->
|
||||||
|
<div class="stat-group clickable" id="routeInfoGroup">
|
||||||
|
<span class="stat-label">Route</span>
|
||||||
|
<span class="stat-value route-text" id="routeInfo">Not Started</span>
|
||||||
|
<span class="edit-icon" title="Reset/Change Route">✎</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="stat-group">
|
||||||
|
<span class="stat-label">Steps</span>
|
||||||
|
<span class="stat-value" id="currentSteps">0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="stat-group">
|
||||||
|
<span class="stat-label">Distance</span>
|
||||||
|
<span class="stat-value" id="tripMeter">0.0 km</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="stat-group">
|
||||||
|
<span class="stat-label">Next Sync</span>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-value" id="nextSync">--:--</span>
|
||||||
|
<button id="refreshButton" class="icon-button" title="Refresh Now">⟳</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Street View Container -->
|
||||||
|
<div id="streetview-container">
|
||||||
|
<div id="panorama"></div>
|
||||||
|
<div id="main-map" class="hidden-mode"></div>
|
||||||
|
|
||||||
|
<!-- Minimap -->
|
||||||
|
<div id="minimap-container">
|
||||||
|
<div id="minimap"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Location Setup Overlay -->
|
||||||
|
<div id="setup-overlay" class="overlay active">
|
||||||
|
<div class="setup-card">
|
||||||
|
<h2>🌍 Plan Your Route</h2>
|
||||||
|
<p>Enter a start and finish location for your walk</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Start Location</label>
|
||||||
|
<input type="text" id="startLocationInput" placeholder="e.g., Central Park, NY" />
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Destination</label>
|
||||||
|
<input type="text" id="endLocationInput" placeholder="e.g., Empire State Building, NY" />
|
||||||
|
</div>
|
||||||
|
<button id="startButton" class="primary-btn">Start Journey</button>
|
||||||
|
|
||||||
|
<div class="separator">
|
||||||
|
<span>OR</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-route-section">
|
||||||
|
<h3>📂 Use Saved Route</h3>
|
||||||
|
<p class="subtext">Browse and select from your uploaded KML files</p>
|
||||||
|
<button id="browseKmlButton" class="secondary-btn">Browse KML Files</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="api-note">Note: You'll need a Google Maps API key configured</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key Setup Overlay -->
|
||||||
|
<div id="apikey-overlay" class="overlay active">
|
||||||
|
<div class="setup-card">
|
||||||
|
<h2>🔑 Google Maps API Key Required</h2>
|
||||||
|
<p>Enter your Google Maps API key to use Street View</p>
|
||||||
|
<input type="text" id="apiKeyInput" placeholder="Your Google Maps API Key" />
|
||||||
|
<button id="saveApiKeyButton" class="primary-btn">Save & Continue</button>
|
||||||
|
<p class="help-text">
|
||||||
|
<a href="https://developers.google.com/maps/documentation/javascript/get-api-key"
|
||||||
|
target="_blank">
|
||||||
|
How to get an API key →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="app-footer">
|
||||||
|
<a href="privacy.html" target="_blank">Privacy Policy</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Overlay -->
|
||||||
|
<div id="login-overlay" class="overlay">
|
||||||
|
<div class="setup-card">
|
||||||
|
<h2>🔐 Login Required</h2>
|
||||||
|
<p>Please login with your Fitbit account to continue</p>
|
||||||
|
<button id="fitbitLoginButton" class="primary-btn">Login with Fitbit</button>
|
||||||
|
<div class="privacy-link-container">
|
||||||
|
<a href="privacy.html" target="_blank" class="secondary-link">Privacy Policy</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KML Browser Modal -->
|
||||||
|
<div id="kml-browser-overlay" class="overlay">
|
||||||
|
<div class="kml-browser-card">
|
||||||
|
<div class="kml-browser-header">
|
||||||
|
<h2>📂 KML File Browser</h2>
|
||||||
|
<button id="closeKmlBrowser" class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kml-tabs">
|
||||||
|
<button class="kml-tab active" data-tab="my-files">My Files</button>
|
||||||
|
<button class="kml-tab" data-tab="public-files">Public Files</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kml-tab-content">
|
||||||
|
<!-- My Files Tab -->
|
||||||
|
<div id="my-files-tab" class="tab-pane active">
|
||||||
|
<div class="upload-section">
|
||||||
|
<input type="file" id="kmlUploadInput" accept=".kml,.xml" style="display: none;" />
|
||||||
|
<button id="uploadKmlButton" class="primary-btn">📤 Upload KML File</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sort-controls">
|
||||||
|
<label>Sort by:</label>
|
||||||
|
<select id="myFilesSortSelect">
|
||||||
|
<option value="date">Newest First</option>
|
||||||
|
<option value="distance">Distance</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="myFilesList" class="kml-file-list">
|
||||||
|
<p class="empty-message">No KML files uploaded yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Public Files Tab -->
|
||||||
|
<div id="public-files-tab" class="tab-pane">
|
||||||
|
<div class="sort-controls">
|
||||||
|
<label>Sort by:</label>
|
||||||
|
<select id="publicFilesSortSelect">
|
||||||
|
<option value="votes">Highest Votes</option>
|
||||||
|
<option value="date">Most Recent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="publicFilesList" class="kml-file-list">
|
||||||
|
<p class="empty-message">No public KML files available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- General Confirmation Overlay -->
|
||||||
|
<div id="confirm-overlay" class="overlay">
|
||||||
|
<div class="setup-card">
|
||||||
|
<h2 id="confirmTitle">⚠️ Confirm</h2>
|
||||||
|
<p id="confirmMessage">Are you sure you want to proceed?</p>
|
||||||
|
<div style="display: flex; gap: 1rem; width: 100%;">
|
||||||
|
<button id="generalConfirmButton" class="primary-btn" style="flex: 1;">Yes</button>
|
||||||
|
<button id="generalCancelButton" class="control-btn" style="flex: 1;">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
170
frontend/privacy.html
Normal file
170
frontend/privacy.html
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Privacy Policy - Pedestrian Simulator</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #6366f1;
|
||||||
|
--bg-dark: #0f172a;
|
||||||
|
--bg-card: #1e293b;
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #cbd5e1;
|
||||||
|
--border: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: linear-gradient(135deg, var(--primary), #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--primary);
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a href="/" class="back-link">← Back to App</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p>Effective Date: January 11, 2026</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Introduction</h2>
|
||||||
|
<p>Welcome to Pedestrian Simulator. We are committed to protecting your privacy and providing a transparent
|
||||||
|
experience. This policy explains how we handle your data.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Information We Collect</h2>
|
||||||
|
<p>To provide the experience of a walking simulator synchronized with your fitness tracker, we collect the
|
||||||
|
following information from your Fitbit account:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Profile Information:</strong> Your display name and avatar, used to personalize your
|
||||||
|
interface.</li>
|
||||||
|
<li><strong>Activity Data:</strong> Your daily step counts, which are used to determine your position
|
||||||
|
along your walking routes.</li>
|
||||||
|
<li><strong>KML Files:</strong> Any route files you choose to upload to the service.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>How We Use Your Data</h2>
|
||||||
|
<p>Your data is used exclusively to facilitate the core functions of the application:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Calculating progress along your selected routes.</li>
|
||||||
|
<li>Saving your trip state so you can resume where you left off.</li>
|
||||||
|
<li>Displaying your identity in the user menu.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>No Third-Party Sharing</h2>
|
||||||
|
<p><strong>We do not share, sell, or trade your personal information with any third parties.</strong> All
|
||||||
|
data collected is stored securely on our server and is used ONLY for the operation of Pedestrian
|
||||||
|
Simulator.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>KML File Privacy</h2>
|
||||||
|
<p>By default, every KML file you upload is <strong>private</strong>. Only you can see it and use it for
|
||||||
|
your trips.</p>
|
||||||
|
<p>You have the option to make a file <strong>public</strong>. This is strictly an opt-in process. When a
|
||||||
|
file is public:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Other users can see the route name and your display name.</li>
|
||||||
|
<li>Other users can use the route for their own trips.</li>
|
||||||
|
<li>Other users can vote on the quality of the route.</li>
|
||||||
|
</ul>
|
||||||
|
<p>You can toggle a file back to private or delete it at any time.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Data Retention</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
© 2026 Pedestrian Simulator. Built with privacy in mind.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
813
frontend/style.css
Normal file
813
frontend/style.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module code.stevenpolley.net/steven/pedestrian-simulator
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
8
pedestrian-simulator.code-workspace
Normal file
8
pedestrian-simulator.code-workspace
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
18
server/context.go
Normal file
18
server/context.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
296
server/fitbit.go
Normal file
296
server/fitbit.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
595
server/kml.go
Normal file
595
server/kml.go
Normal file
@@ -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 <coordinates> 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)
|
||||||
|
}
|
||||||
134
server/main.go
Normal file
134
server/main.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
159
server/session.go
Normal file
159
server/session.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
269
server/step_manager.go
Normal file
269
server/step_manager.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
156
server/user.go
Normal file
156
server/user.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user