From f0172afb1ea0bb844682042dd8d0bdec6097b9a4 Mon Sep 17 00:00:00 2001 From: Steven Polley Date: Wed, 14 Jan 2026 16:55:49 -0700 Subject: [PATCH] show completed trips on profiles --- frontend/app.js | 209 ++++++++++++++++++++++++++++++++++++++++++-- frontend/index.html | 42 ++++++++- frontend/style.css | 188 ++++++++++++++++++++++++++++++++++++++- server/db.go | 13 +++ server/kml.go | 117 +++++++++++++++++++++++-- server/main.go | 5 +- 6 files changed, 556 insertions(+), 18 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index c9f5b86..c102481 100644 --- a/frontend/app.js +++ b/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) {
- + ${!isOwnFiles ? ` +
+ + `).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 = '

No completed walks yet.

'; + } + // 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 = '

Failed to load user profile.

'; + document.getElementById('profileCompletedTripsList').innerHTML = '

Error loading profile.

'; } } @@ -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); + } +} diff --git a/frontend/index.html b/frontend/index.html index 9d70a0a..cacdca3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -266,10 +266,23 @@
-

Completed Walks (Public)

-
-

No public walks shared yet.

+
+ +
+ +
+
+

No completed walks yet.

+
+
+ +
+
+

No public walks shared yet.

+
+
+
1 @@ -278,6 +291,29 @@
+ + +
+
+
+
+
🎉
+

Journey Complete!

+

Congratulations! You've successfully finished your walk.

+
+
+ Route + - +
+
+ Distance + 0.0 km +
+
+ +
+
+
diff --git a/frontend/style.css b/frontend/style.css index dd21a0b..9ced7b8 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1057,4 +1057,190 @@ body { width: 95%; padding: 1.5rem; } -} \ No newline at end of file +} + +/* 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; +} diff --git a/server/db.go b/server/db.go index 6c722ba..9ae6a07 100644 --- a/server/db.go +++ b/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 { diff --git a/server/kml.go b/server/kml.go index 1934e74..79df41a 100644 --- a/server/kml.go +++ b/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 } + // 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(user) + 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) } diff --git a/server/main.go b/server/main.go index e19af70..11a61bf 100644 --- a/server/main.go +++ b/server/main.go @@ -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)))