initial commit
All checks were successful
pedestrian-simulator / build (push) Successful in 53s

This commit is contained in:
2026-01-11 17:16:59 -07:00
parent c5d00dc9a9
commit 24ecddd034
18 changed files with 4506 additions and 4 deletions

View 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
View File

@@ -0,0 +1,4 @@
env.sh
*.exe
*.json
*.kml

11
Dockerfile Normal file
View 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" ]

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

200
frontend/index.html Normal file
View 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
View 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>
&copy; 2026 Pedestrian Simulator. Built with privacy in mind.
</footer>
</div>
</body>
</html>

813
frontend/style.css Normal file
View 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
View File

@@ -0,0 +1,3 @@
module code.stevenpolley.net/steven/pedestrian-simulator
go 1.25.5

View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

18
server/context.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}