show completed trips on profiles
All checks were successful
pedestrian-simulator / build (push) Successful in 59s
All checks were successful
pedestrian-simulator / build (push) Successful in 59s
This commit is contained in:
209
frontend/app.js
209
frontend/app.js
@@ -107,10 +107,22 @@ function updateUserProfile(user) {
|
||||
const avatarEl = document.getElementById('userAvatar');
|
||||
const defaultAvatar = 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png';
|
||||
|
||||
if (nameEl && user.displayName) nameEl.textContent = user.displayName;
|
||||
if (nameEl && user.displayName) {
|
||||
nameEl.textContent = user.displayName;
|
||||
nameEl.style.cursor = 'pointer';
|
||||
if (!nameEl.hasListener) {
|
||||
nameEl.addEventListener('click', () => openUserProfile(currentUserID));
|
||||
nameEl.hasListener = true;
|
||||
}
|
||||
}
|
||||
if (avatarEl) {
|
||||
avatarEl.src = user.avatarUrl || defaultAvatar;
|
||||
avatarEl.style.cursor = 'pointer';
|
||||
avatarEl.onerror = () => { avatarEl.src = defaultAvatar; };
|
||||
if (!avatarEl.hasListener) {
|
||||
avatarEl.addEventListener('click', () => openUserProfile(currentUserID));
|
||||
avatarEl.hasListener = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +381,7 @@ function createKMLFileHTML(file, isOwnFiles, listId, index) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="kml-file-actions">
|
||||
<button id="open-${uniqueId}" class="primary-btn small" title="View Details">📂 Open</button>
|
||||
<button id="open-${uniqueId}" class="primary-btn small" title="Start this trip">▶️ Start Trip</button>
|
||||
${!isOwnFiles ? `
|
||||
<button id="upvote-${uniqueId}" class="vote-btn" title="Upvote">
|
||||
👍 <span class="vote-count">${file.votes}</span>
|
||||
@@ -907,6 +919,9 @@ function setupEventListeners() {
|
||||
async function calculateAndStartRoute(startAddress, endAddress, isRestoring = false) {
|
||||
if (!window.google) return;
|
||||
|
||||
// Reset celebration state immediately on start attempt
|
||||
window.hasCelebrated = false;
|
||||
|
||||
// Use Directions Service to finding route
|
||||
if (!directionsService) {
|
||||
directionsService = new google.maps.DirectionsService();
|
||||
@@ -945,6 +960,14 @@ async function calculateAndStartRoute(startAddress, endAddress, isRestoring = fa
|
||||
// Save state
|
||||
localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData));
|
||||
|
||||
currentTripDetails = {
|
||||
type: 'address',
|
||||
route_name: `${leg.start_address} to ${leg.end_address}`,
|
||||
start_address: leg.start_address,
|
||||
end_address: leg.end_address,
|
||||
distance: routeTotalDistance / 1000 // In km
|
||||
};
|
||||
|
||||
// Initialize step reference
|
||||
// Only start a new trip on server if this is a fresh start, not a restore
|
||||
if (!isRestoring) {
|
||||
@@ -1004,6 +1027,9 @@ async function calculateAndStartRoute(startAddress, endAddress, isRestoring = fa
|
||||
function startFromLocation(locationLatLng, address) {
|
||||
const location = locationLatLng;
|
||||
|
||||
// Reset celebration state
|
||||
window.hasCelebrated = false;
|
||||
|
||||
// Initialize Street View panorama
|
||||
panorama = new google.maps.StreetViewPanorama(
|
||||
document.getElementById('panorama'),
|
||||
@@ -1176,8 +1202,12 @@ async function triggerDrain() {
|
||||
// Step tracking
|
||||
// Using a 6-second delay buffer to ensure smooth interpolation between past known points.
|
||||
// This prevents overshooting/guessing.
|
||||
const INTERPOLATION_DELAY = 6000; // 6 seconds
|
||||
let stateBuffer = []; // Array of {t: timestamp, s: steps}
|
||||
const INTERPOLATION_DELAY = 6000;
|
||||
// State variables
|
||||
let currentTripDetails = null;
|
||||
window.hasCelebrated = false;
|
||||
let stateBuffer = [];
|
||||
// Array of {t: timestamp, s: steps}
|
||||
let displayTripSteps = 0; // Current interpolated value
|
||||
|
||||
|
||||
@@ -1441,6 +1471,14 @@ function startCameraAnimation() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- TRIP COMPLETION CHECK ---
|
||||
if (masterPath.length > 0 && routeTotalDistance > 0 && !window.hasCelebrated) {
|
||||
if (distanceTraveled >= routeTotalDistance) {
|
||||
window.hasCelebrated = true;
|
||||
triggerCelebration(distanceTraveled);
|
||||
}
|
||||
}
|
||||
|
||||
idleAnimationId = requestAnimationFrame(animate);
|
||||
} catch (e) {
|
||||
console.error("Animation Loop Crash:", e);
|
||||
@@ -1497,15 +1535,45 @@ async function openUserProfile(userId) {
|
||||
updateURLState('profile', { userId: userId });
|
||||
|
||||
try {
|
||||
// 1. Fetch User Info
|
||||
// 1. Fetch User Info & Completed Trips
|
||||
const userResp = await fetch(`/api/user/profile?user_id=${userId}`);
|
||||
if (!userResp.ok) throw new Error('User not found');
|
||||
const user = await userResp.json();
|
||||
const profileData = await userResp.json();
|
||||
const user = profileData.user;
|
||||
|
||||
document.getElementById('profileDisplayName').textContent = user.display_name || 'Unknown User';
|
||||
document.getElementById('profileFitbitID').textContent = `@${user.fitbit_user_id}`;
|
||||
document.getElementById('profileAvatar').src = user.avatar_url || 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png';
|
||||
|
||||
// Render Completed Trips
|
||||
const completedListEl = document.getElementById('profileCompletedTripsList');
|
||||
if (profileData.completed_trips && profileData.completed_trips.length > 0) {
|
||||
completedListEl.innerHTML = profileData.completed_trips.map((trip, index) => `
|
||||
<div class="kml-file-item">
|
||||
<div class="kml-file-info">
|
||||
<div class="kml-file-name">${escapeHtml(trip.route_name)}</div>
|
||||
<div class="kml-file-meta">
|
||||
<span>📏 ${trip.distance.toFixed(2)} km</span>
|
||||
<span class="trip-date">📅 ${new Date(trip.completed_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kml-file-actions">
|
||||
<button id="start-completed-${index}" class="primary-btn small-btn">▶️ Start Trip</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Attach listeners for completed trips
|
||||
profileData.completed_trips.forEach((trip, index) => {
|
||||
document.getElementById(`start-completed-${index}`).addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Avoid triggering potential row clicks
|
||||
startCompletedTrip(trip);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
completedListEl.innerHTML = '<p class="empty-message">No completed walks yet.</p>';
|
||||
}
|
||||
|
||||
// 2. Fetch Public KMLs
|
||||
currentProfileUserId = userId;
|
||||
currentProfilePage = 1;
|
||||
@@ -1515,6 +1583,7 @@ async function openUserProfile(userId) {
|
||||
console.error("Error loading profile:", err);
|
||||
document.getElementById('profileDisplayName').textContent = 'Error';
|
||||
document.getElementById('profilePublicTripsList').innerHTML = '<p class="empty-message">Failed to load user profile.</p>';
|
||||
document.getElementById('profileCompletedTripsList').innerHTML = '<p class="empty-message">Error loading profile.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1601,6 +1670,26 @@ function setupUserProfileListeners() {
|
||||
currentProfilePage++;
|
||||
loadProfilePublicFiles();
|
||||
});
|
||||
|
||||
// Profile Tabs
|
||||
document.querySelectorAll('.profile-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const tabName = tab.dataset.tab;
|
||||
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.profile-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
// Update panes
|
||||
document.querySelectorAll('.profile-pane').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(`${tabName}-pane`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Close Celebration
|
||||
document.getElementById('closeCelebration').addEventListener('click', () => {
|
||||
document.getElementById('celebration-overlay').classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2025,6 +2114,16 @@ function startFromCustomRoute(pathData, routeName, isRestoring = false, markers
|
||||
|
||||
localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData));
|
||||
|
||||
// Reset celebration state
|
||||
window.hasCelebrated = false;
|
||||
currentTripDetails = {
|
||||
type: 'kml',
|
||||
route_name: routeName,
|
||||
kml_filename: locationData.routeName,
|
||||
kml_owner_id: currentUserID,
|
||||
distance: routeTotalDistance / 1000 // In km
|
||||
};
|
||||
|
||||
// Initialize/Reset Server Trip
|
||||
if (!isRestoring) {
|
||||
fetch('/api/trip', { method: 'POST' }).catch(console.error);
|
||||
@@ -2157,3 +2256,101 @@ function renderTripMarkers(markers) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- Celebration & Trip Re-entry Helpers ---
|
||||
|
||||
async function triggerCelebration(finalDistance) {
|
||||
console.log("🎉 Celebration Triggered!");
|
||||
const overlay = document.getElementById('celebration-overlay');
|
||||
const routeNameEl = document.getElementById('finishRouteName');
|
||||
const distanceEl = document.getElementById('finishDistance');
|
||||
|
||||
if (currentTripDetails) {
|
||||
routeNameEl.textContent = currentTripDetails.route_name;
|
||||
currentTripDetails.distance = finalDistance / 1000; // Store in km
|
||||
}
|
||||
distanceEl.textContent = `${(finalDistance / 1000).toFixed(2)} km`;
|
||||
|
||||
overlay.classList.add('active');
|
||||
createConfetti();
|
||||
|
||||
// Report to backend
|
||||
if (currentTripDetails) {
|
||||
try {
|
||||
await fetch('/api/trip/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: currentTripDetails.type,
|
||||
route_name: currentTripDetails.route_name,
|
||||
start_address: currentTripDetails.start_address || '',
|
||||
end_address: currentTripDetails.end_address || '',
|
||||
kml_filename: currentTripDetails.kml_filename || '',
|
||||
kml_owner_id: currentTripDetails.kml_owner_id || '',
|
||||
distance: currentTripDetails.distance
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to record trip completion:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createConfetti() {
|
||||
const container = document.querySelector('.confetti-container');
|
||||
if (!container) return;
|
||||
const colors = ['#6366f1', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const confetti = document.createElement('div');
|
||||
confetti.className = 'confetti';
|
||||
confetti.style.left = Math.random() * 100 + '%';
|
||||
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
confetti.style.width = Math.random() * 8 + 4 + 'px';
|
||||
confetti.style.height = Math.random() * 8 + 4 + 'px';
|
||||
confetti.style.opacity = Math.random();
|
||||
confetti.style.borderRadius = '50%';
|
||||
|
||||
const duration = Math.random() * 3 + 2;
|
||||
confetti.style.top = '-10px';
|
||||
confetti.style.position = 'absolute';
|
||||
confetti.style.animation = `confetti-fall ${duration}s linear forwards`;
|
||||
confetti.style.animationDelay = Math.random() * 2 + 's';
|
||||
|
||||
container.appendChild(confetti);
|
||||
|
||||
// Remove after animation
|
||||
setTimeout(() => confetti.remove(), (duration + 2) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function startCompletedTrip(trip) {
|
||||
console.log("Reviewing completed trip re-entry:", trip);
|
||||
|
||||
if (trip.trip_type === 'address') {
|
||||
// Pre-fill setup overlay
|
||||
document.getElementById('startLocationInput').value = trip.start_address;
|
||||
document.getElementById('endLocationInput').value = trip.end_address;
|
||||
|
||||
// Hide profile, show setup
|
||||
document.getElementById('user-profile-overlay').classList.remove('active');
|
||||
showSetupOverlay();
|
||||
|
||||
// Update URL to home/setup state
|
||||
updateURLState('home', null);
|
||||
|
||||
} else if (trip.trip_type === 'kml') {
|
||||
// Open KML details with metadata from trip
|
||||
// Note: HandleUserProfile now joins metadata info
|
||||
const file = {
|
||||
filename: trip.kml_filename,
|
||||
user_id: trip.kml_owner_id,
|
||||
display_name: trip.kml_display_name,
|
||||
distance: trip.distance,
|
||||
votes: trip.kml_votes,
|
||||
description: trip.kml_description,
|
||||
is_public: true
|
||||
};
|
||||
openKMLDetails(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,10 +266,23 @@
|
||||
</div>
|
||||
|
||||
<div class="user-profile-content">
|
||||
<h3>Completed Walks (Public)</h3>
|
||||
<div class="profile-tabs">
|
||||
<button class="profile-tab active" data-tab="completed-walks">🏅 Completed Walks</button>
|
||||
<button class="profile-tab" data-tab="shared-routes">🌍 Shared Routes</button>
|
||||
</div>
|
||||
|
||||
<div id="completed-walks-pane" class="profile-pane active">
|
||||
<div id="profileCompletedTripsList" class="kml-file-list">
|
||||
<p class="empty-message">No completed walks yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="shared-routes-pane" class="profile-pane">
|
||||
<div id="profilePublicTripsList" class="kml-file-list">
|
||||
<p class="empty-message">No public walks shared yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls small">
|
||||
<button id="prevProfilePage" class="action-btn" disabled>←</button>
|
||||
<span id="profilePageNum">1</span>
|
||||
@@ -278,6 +291,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Celebration Overlay -->
|
||||
<div id="celebration-overlay" class="overlay">
|
||||
<div class="celebration-card">
|
||||
<div class="confetti-container"></div>
|
||||
<div class="celebration-content">
|
||||
<div class="celebration-icon">🎉</div>
|
||||
<h2>Journey Complete!</h2>
|
||||
<p>Congratulations! You've successfully finished your walk.</p>
|
||||
<div id="celebrationStats" class="celebration-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Route</span>
|
||||
<span id="finishRouteName" class="stat-value">-</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Distance</span>
|
||||
<span id="finishDistance" class="stat-value">0.0 km</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="closeCelebration" class="primary-btn large">Awesome!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -1058,3 +1058,189 @@ body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Celebration Overlay */
|
||||
.celebration-card {
|
||||
background: var(--bg-card);
|
||||
padding: 3rem;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.6);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
border: 2px solid var(--primary);
|
||||
position: relative;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
animation: celebrate-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes celebrate-pop {
|
||||
from { transform: scale(0.8); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.celebration-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.celebration-content h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.celebration-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.celebration-stats .stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Confetti Animation */
|
||||
.confetti-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.confetti {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@keyframes confetti-fall {
|
||||
from { transform: translateY(-100%) rotate(0deg); opacity: 1; }
|
||||
to { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Updated User Profile Styles */
|
||||
.user-profile-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.user-profile-header {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.user-profile-info {
|
||||
text-align: center;
|
||||
padding: 0 2rem 2rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.profile-avatar-large {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--primary);
|
||||
margin-bottom: 1rem;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.profile-fitbit-id {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
margin-top: 0.25rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.user-profile-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.profile-tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-pane {
|
||||
display: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.profile-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.trip-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.pagination-controls.small {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
13
server/db.go
13
server/db.go
@@ -107,6 +107,19 @@ func createTables() {
|
||||
PRIMARY KEY (user_id, date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS completed_trips (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
trip_type ENUM('address', 'kml') DEFAULT 'address',
|
||||
route_name TEXT,
|
||||
start_address TEXT,
|
||||
end_address TEXT,
|
||||
kml_id INT,
|
||||
distance DOUBLE,
|
||||
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(fitbit_user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (kml_id) REFERENCES kml_metadata(id) ON DELETE SET NULL
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
|
||||
119
server/kml.go
119
server/kml.go
@@ -52,6 +52,24 @@ type KMLMetadata struct {
|
||||
Votes int `json:"votes"` // Net votes (calculated from voting system)
|
||||
}
|
||||
|
||||
// CompletedTrip Metadata
|
||||
type CompletedTrip struct {
|
||||
ID int `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
TripType string `json:"trip_type"`
|
||||
RouteName string `json:"route_name"`
|
||||
StartAddress string `json:"start_address"`
|
||||
EndAddress string `json:"end_address"`
|
||||
KmlID *int `json:"kml_id"`
|
||||
KmlFilename string `json:"kml_filename,omitempty"`
|
||||
KmlOwnerID string `json:"kml_owner_id,omitempty"`
|
||||
KmlDisplayName string `json:"kml_display_name,omitempty"`
|
||||
KmlVotes int `json:"kml_votes,omitempty"`
|
||||
KmlDescription string `json:"kml_description,omitempty"`
|
||||
Distance float64 `json:"distance"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
// SetVote sets a user's vote for a KML file in the database
|
||||
func SetVote(kmlID int, userID string, vote int) error {
|
||||
if vote == 0 {
|
||||
@@ -651,12 +669,6 @@ func HandleKMLDownload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleUserProfile serves public user profile data
|
||||
func HandleUserProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// Authentication optional? Yes, profiles should be public.
|
||||
// But let's check auth just to be safe if we want to restrict it to logged-in users later.
|
||||
// Current requirement: "implement profile pages... open the KML details overlay... so that you can do it too".
|
||||
// Implies logged in users mostly, but let's allow it generally if the files are public.
|
||||
|
||||
// Get target user ID from URL
|
||||
targetID := r.URL.Query().Get("user_id")
|
||||
if targetID == "" {
|
||||
http.Error(w, "Missing user_id", http.StatusBadRequest)
|
||||
@@ -673,6 +685,97 @@ func HandleUserProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
// Fetch completed trips
|
||||
rows, err := db.Query(`
|
||||
SELECT ct.id, ct.trip_type, ct.route_name, ct.start_address, ct.end_address, ct.kml_id, ct.distance, ct.completed_at,
|
||||
m.filename, m.user_id, u.display_name, m.description,
|
||||
COALESCE((SELECT SUM(vote) FROM kml_votes WHERE kml_id = m.id), 0) as votes
|
||||
FROM completed_trips ct
|
||||
LEFT JOIN kml_metadata m ON ct.kml_id = m.id
|
||||
LEFT JOIN users u ON m.user_id = u.fitbit_user_id
|
||||
WHERE ct.user_id = ?
|
||||
ORDER BY ct.completed_at DESC
|
||||
LIMIT 20
|
||||
`, targetID)
|
||||
|
||||
var completedTrips []CompletedTrip
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ct CompletedTrip
|
||||
var kmlID sql.NullInt64
|
||||
var kmlFilename, kmlOwnerID, kmlDisplayName, kmlDescription sql.NullString
|
||||
var kmlVotes sql.NullInt64
|
||||
err := rows.Scan(&ct.ID, &ct.TripType, &ct.RouteName, &ct.StartAddress, &ct.EndAddress, &kmlID, &ct.Distance, &ct.CompletedAt,
|
||||
&kmlFilename, &kmlOwnerID, &kmlDisplayName, &kmlDescription, &kmlVotes)
|
||||
if err == nil {
|
||||
if kmlID.Valid {
|
||||
id := int(kmlID.Int64)
|
||||
ct.KmlID = &id
|
||||
ct.KmlFilename = kmlFilename.String
|
||||
ct.KmlOwnerID = kmlOwnerID.String
|
||||
ct.KmlDisplayName = kmlDisplayName.String
|
||||
ct.KmlDescription = kmlDescription.String
|
||||
ct.KmlVotes = int(kmlVotes.Int64)
|
||||
}
|
||||
completedTrips = append(completedTrips, ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"user": user,
|
||||
"completed_trips": completedTrips,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleTripComplete records a completed trip
|
||||
func HandleTripComplete(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 {
|
||||
Type string `json:"type"`
|
||||
RouteName string `json:"route_name"`
|
||||
StartAddress string `json:"start_address"`
|
||||
EndAddress string `json:"end_address"`
|
||||
KmlFilename string `json:"kml_filename"`
|
||||
KmlOwnerID string `json:"kml_owner_id"`
|
||||
Distance float64 `json:"distance"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var kmlID interface{} = nil
|
||||
if req.Type == "kml" {
|
||||
var id int
|
||||
err := db.QueryRow("SELECT id FROM kml_metadata WHERE user_id = ? AND filename = ?", req.KmlOwnerID, req.KmlFilename).Scan(&id)
|
||||
if err == nil {
|
||||
kmlID = id
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO completed_trips (user_id, trip_type, route_name, start_address, end_address, kml_id, distance)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, userID, req.Type, req.RouteName, req.StartAddress, req.EndAddress, kmlID, req.Distance)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save completed trip: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -140,7 +140,10 @@ func main() {
|
||||
// 7. User Profile Endpoint
|
||||
http.HandleFunc("/api/user/profile", RequireAuth(HandleUserProfile))
|
||||
|
||||
// 8. Start Server
|
||||
// 8. Trip Completion Endpoint
|
||||
http.HandleFunc("/api/trip/complete", RequireAuth(HandleTripComplete))
|
||||
|
||||
// 9. Start Server
|
||||
binding := "0.0.0.0:8080"
|
||||
fmt.Printf("Server starting on http://%s\n", binding)
|
||||
log.Fatal(http.ListenAndServe(binding, RecoveryMiddleware(http.DefaultServeMux)))
|
||||
|
||||
Reference in New Issue
Block a user