show completed trips on profiles
All checks were successful
pedestrian-simulator / build (push) Successful in 59s

This commit is contained in:
2026-01-14 16:55:49 -07:00
parent bf75e10399
commit f0172afb1e
6 changed files with 556 additions and 18 deletions

View File

@@ -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);
}
}

View File

@@ -266,10 +266,23 @@
</div>
<div class="user-profile-content">
<h3>Completed Walks (Public)</h3>
<div id="profilePublicTripsList" class="kml-file-list">
<p class="empty-message">No public walks shared yet.</p>
<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>

View File

@@ -1057,4 +1057,190 @@ body {
width: 95%;
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;
}