1851 lines
93 KiB
HTML
1851 lines
93 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en"
|
|
xmlns:th="http://www.thymeleaf.org"
|
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
|
layout:decorate="~{layout}">
|
|
|
|
<head>
|
|
<title>Activity Details</title>
|
|
</head>
|
|
|
|
<body>
|
|
<div layout:fragment="content">
|
|
<!-- Loading Indicator -->
|
|
<div id="loadingIndicator" class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p class="mt-2 text-muted">Loading activity...</p>
|
|
</div>
|
|
|
|
<!-- Error Alert -->
|
|
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
|
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
|
<span id="errorMessage"></span>
|
|
</div>
|
|
|
|
<!-- Activity Content -->
|
|
<div id="activityContent" class="d-none">
|
|
<!-- Activity Header -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<h2 id="activityTitle">Activity Title</h2>
|
|
<p class="text-muted mb-2">
|
|
<span id="activityType" class="activity-type-badge"></span>
|
|
<span id="raceBadge" class="badge race-badge ms-2" style="display: none;">
|
|
<i class="bi bi-flag-checkered"></i> Race
|
|
</span>
|
|
<span id="indoorBadge" class="badge bg-warning text-dark ms-2" style="display: none;">
|
|
<i class="bi bi-house-door"></i> Indoor
|
|
</span>
|
|
<span class="ms-2">
|
|
<i class="bi bi-calendar"></i>
|
|
<span id="activityDate"></span>
|
|
</span>
|
|
<span class="ms-2">
|
|
<i class="bi bi-geo-alt"></i>
|
|
<span id="activityLocation"></span>
|
|
</span>
|
|
<span class="ms-2" id="visibilityBadge">
|
|
<i class="bi bi-globe"></i>
|
|
<span id="activityVisibility"></span>
|
|
</span>
|
|
</p>
|
|
<p id="activityDescription" class="text-muted"></p>
|
|
</div>
|
|
<div class="btn-group" role="group" id="activityActions" style="display: none;">
|
|
<a href="#" id="editBtn" class="btn btn-outline-primary">
|
|
<i class="bi bi-pencil"></i> Edit
|
|
</a>
|
|
<button id="deleteBtn" class="btn btn-outline-danger">
|
|
<i class="bi bi-trash"></i> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Activity Metrics -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card border shadow-sm">
|
|
<div class="card-body">
|
|
<div class="row g-2">
|
|
<!-- Distance -->
|
|
<div class="col-md-3 col-6">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-signpost-2 text-primary me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Distance</div>
|
|
<div class="fw-bold" id="metricDistance">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Duration -->
|
|
<div class="col-md-3 col-6">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-clock text-primary me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Duration</div>
|
|
<div class="fw-bold" id="metricDuration">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Moving Time -->
|
|
<div class="col-md-3 col-6" id="metricMovingTimeContainer" style="display: none;">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-clock-history text-primary me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Moving Time</div>
|
|
<div class="fw-bold" id="metricMovingTime">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Elevation Gain -->
|
|
<div class="col-md-3 col-6">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-graph-up-arrow text-primary me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Elevation</div>
|
|
<div class="fw-bold" id="metricElevationGain">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Avg Pace -->
|
|
<div class="col-md-3 col-6">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-speedometer text-primary me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Avg Pace</div>
|
|
<div class="fw-bold" id="metricPace">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Avg Speed -->
|
|
<div class="col-md-3 col-6" id="metricAvgSpeedContainer" style="display: none;">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-speedometer2 text-primary me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Avg Speed</div>
|
|
<div class="fw-bold" id="metricAvgSpeed">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Avg Heart Rate -->
|
|
<div class="col-md-3 col-6" id="metricAvgHRContainer" style="display: none;">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-heart text-danger me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Avg HR</div>
|
|
<div class="fw-bold" id="metricAvgHR">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Calories -->
|
|
<div class="col-md-3 col-6" id="metricCaloriesContainer" style="display: none;">
|
|
<div class="d-flex align-items-center py-2">
|
|
<i class="bi bi-fire text-orange me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<div class="text-muted small">Calories</div>
|
|
<div class="fw-bold" id="metricCalories">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Peaks Card -->
|
|
<div class="row mb-4" id="peaksSection" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-triangle"></i> Peaks
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<ul class="list-group list-group-flush" id="peaksList">
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Weather Card -->
|
|
<div class="row mb-4" id="weatherSection" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-cloud-sun"></i> Weather Conditions
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-2 text-center">
|
|
<div id="weatherEmoji" style="font-size: 4rem;">🌡️</div>
|
|
<div id="weatherCondition" class="text-muted">--</div>
|
|
</div>
|
|
<div class="col-md-10">
|
|
<div class="row">
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Temperature</div>
|
|
<div class="fw-bold fs-4" id="weatherTemp">--</div>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Feels Like</div>
|
|
<div class="fw-bold" id="weatherFeelsLike">--</div>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Humidity</div>
|
|
<div class="fw-bold" id="weatherHumidity">--</div>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Wind</div>
|
|
<div class="fw-bold" id="weatherWind">--</div>
|
|
</div>
|
|
</div>
|
|
<div class="row mt-2">
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Pressure</div>
|
|
<div class="fw-bold" id="weatherPressure">--</div>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Visibility</div>
|
|
<div class="fw-bold" id="weatherVisibility">--</div>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Cloudiness</div>
|
|
<div class="fw-bold" id="weatherCloudiness">--</div>
|
|
</div>
|
|
<div class="col-md-3 mb-2" id="weatherPrecipSection" style="display: none;">
|
|
<div class="text-muted small">Precipitation</div>
|
|
<div class="fw-bold" id="weatherPrecip">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map / Indoor Activity Placeholder -->
|
|
<div class="row mb-4" id="mapSection">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-map"></i> Route Map
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div id="activityMap" class="map-container-large"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Indoor Activity Placeholder -->
|
|
<div class="row mb-4" id="indoorPlaceholder" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body text-center py-5">
|
|
<div id="activityTypeEmoji" style="font-size: 5rem;" class="mb-3">🏋️</div>
|
|
<h4 id="activityTypeName" class="text-muted">Indoor Activity</h4>
|
|
<p class="text-muted">No GPS track available</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row -->
|
|
<div class="row mb-4" id="chartsSection" style="display: none;">
|
|
<!-- Elevation Chart -->
|
|
<div class="col-lg-4 col-md-12 mb-3 mb-lg-0" id="elevationSection" style="display: none;">
|
|
<div class="card h-100">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="bi bi-graph-up"></i> Elevation
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="elevationChart" height="120"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Heart Rate Chart -->
|
|
<div class="col-lg-4 col-md-12 mb-3 mb-lg-0" id="heartRateSection" style="display: none;">
|
|
<div class="card h-100">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="bi bi-heart-pulse"></i> Heart Rate
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="heartRateChart" height="120"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Speed/Pace Chart -->
|
|
<div class="col-lg-4 col-md-12 mb-3 mb-lg-0" id="speedSection" style="display: none;">
|
|
<div class="card h-100">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0">
|
|
<i class="bi bi-speedometer2"></i> Speed/Pace
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="speedChart" height="120"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Additional Metrics -->
|
|
<div class="row mb-4" id="additionalMetrics" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-speedometer2"></i> Additional Metrics
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-4 mb-3" id="avgHeartRateContainer" style="display: none;">
|
|
<strong>Average Heart Rate:</strong>
|
|
<span id="avgHeartRate" class="float-end">-- bpm</span>
|
|
</div>
|
|
<div class="col-md-4 mb-3" id="maxHeartRateContainer" style="display: none;">
|
|
<strong>Max Heart Rate:</strong>
|
|
<span id="maxHeartRate" class="float-end">-- bpm</span>
|
|
</div>
|
|
<div class="col-md-4 mb-3" id="avgCadenceContainer" style="display: none;">
|
|
<strong>Average Cadence:</strong>
|
|
<span id="avgCadence" class="float-end">-- rpm</span>
|
|
</div>
|
|
<div class="col-md-4 mb-3" id="avgSpeedContainer" style="display: none;">
|
|
<strong>Average Speed:</strong>
|
|
<span id="avgSpeed" class="float-end">-- km/h</span>
|
|
</div>
|
|
<div class="col-md-4 mb-3" id="maxSpeedContainer" style="display: none;">
|
|
<strong>Max Speed:</strong>
|
|
<span id="maxSpeed" class="float-end">-- km/h</span>
|
|
</div>
|
|
<div class="col-md-4 mb-3" id="caloriesContainer" style="display: none;">
|
|
<strong>Calories:</strong>
|
|
<span id="calories" class="float-end">-- kcal</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Social Interactions -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">
|
|
<i class="bi bi-chat-heart"></i> Social
|
|
</h5>
|
|
<div>
|
|
<button id="likeBtn" class="btn btn-sm btn-outline-danger me-2">
|
|
<i class="bi bi-heart"></i>
|
|
<span id="likeBtnText">Like</span>
|
|
(<span id="likeCount">0</span>)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Likes Section -->
|
|
<div class="mb-4" id="likesSection">
|
|
<h6 class="text-muted mb-3">
|
|
<i class="bi bi-heart-fill text-danger"></i>
|
|
Liked by <span id="likesCountText">0</span>
|
|
</h6>
|
|
<div id="likesList" class="d-flex flex-wrap gap-2">
|
|
<!-- Likes will be populated here -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comments Section -->
|
|
<div>
|
|
<h6 class="text-muted mb-3">
|
|
<i class="bi bi-chat-left-text"></i>
|
|
Comments (<span id="commentsCount">0</span>)
|
|
</h6>
|
|
|
|
<!-- Comment Form -->
|
|
<div id="commentForm" class="mb-4" style="display: none;">
|
|
<form id="addCommentForm">
|
|
<div class="mb-3">
|
|
<textarea
|
|
id="commentContent"
|
|
class="form-control"
|
|
rows="3"
|
|
placeholder="Write a comment..."
|
|
required
|
|
></textarea>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-sm">
|
|
<i class="bi bi-send"></i> Post Comment
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Comments List -->
|
|
<div id="commentsList">
|
|
<!-- Comments will be populated here -->
|
|
</div>
|
|
|
|
<!-- Login prompt for non-authenticated users -->
|
|
<div id="loginPrompt" style="display: none;">
|
|
<p class="text-muted">
|
|
<a href="/login">Log in</a> to like or comment on this activity.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Back Button -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<a th:href="@{/activities}" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left"></i> Back to Activities
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="deleteModalLabel">
|
|
<i class="bi bi-exclamation-triangle text-danger"></i>
|
|
Delete Activity
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Are you sure you want to delete this activity?</p>
|
|
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
|
<i class="bi bi-trash"></i> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom Scripts -->
|
|
<th:block layout:fragment="scripts">
|
|
<script th:inline="javascript">
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const activityId = window.location.pathname.split('/').pop();
|
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
|
const errorAlert = document.getElementById('errorAlert');
|
|
const errorMessage = document.getElementById('errorMessage');
|
|
const activityContent = document.getElementById('activityContent');
|
|
|
|
// Global variables for map interaction
|
|
let activityMap = null;
|
|
let hoverMarker = null;
|
|
let currentTrackPoints = null;
|
|
|
|
/**
|
|
* Throttle function to limit how often a function can be called
|
|
* @param {Function} func - Function to throttle
|
|
* @param {number} limit - Minimum time between calls in milliseconds
|
|
* @returns {Function} Throttled function
|
|
*/
|
|
function throttle(func, limit) {
|
|
let inThrottle;
|
|
return function(...args) {
|
|
if (!inThrottle) {
|
|
func.apply(this, args);
|
|
inThrottle = true;
|
|
setTimeout(() => inThrottle = false, limit);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Load activity details
|
|
loadActivity();
|
|
|
|
async function loadActivity() {
|
|
try {
|
|
// Use authenticated fetch if user is logged in, otherwise regular fetch
|
|
// This allows public activities to be viewed without authentication
|
|
const response = FitPubAuth.isAuthenticated()
|
|
? await FitPubAuth.authenticatedFetch(`/api/activities/${activityId}`)
|
|
: await fetch(`/api/activities/${activityId}`);
|
|
|
|
if (response.ok) {
|
|
const activity = await response.json();
|
|
renderActivity(activity);
|
|
|
|
// Hide loading, show content
|
|
loadingIndicator.classList.add('d-none');
|
|
activityContent.classList.remove('d-none');
|
|
} else {
|
|
throw new Error('Failed to load activity');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading activity:', error);
|
|
loadingIndicator.classList.add('d-none');
|
|
errorMessage.textContent = 'Failed to load activity. Please try again.';
|
|
errorAlert.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
function renderActivity(activity) {
|
|
// Header
|
|
document.getElementById('activityTitle').textContent = activity.title || 'Untitled Activity';
|
|
document.getElementById('activityType').textContent = activity.activityType;
|
|
document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`;
|
|
// Format date with timezone awareness
|
|
document.getElementById('activityDate').textContent = FitPub.formatDateTimeWithTimezone(
|
|
activity.startedAt,
|
|
activity.timezone || 'UTC'
|
|
);
|
|
document.getElementById('activityLocation').textContent = activity.activityLocation;
|
|
document.getElementById('activityVisibility').textContent = activity.visibility;
|
|
|
|
// Visibility icon
|
|
const visIcon = getVisibilityIcon(activity.visibility);
|
|
document.querySelector('#visibilityBadge i').className = `bi bi-${visIcon}`;
|
|
document.getElementById('visibilityBadge').className = `ms-2 visibility-${activity.visibility.toLowerCase()}`;
|
|
|
|
// Race badge and styling
|
|
const raceBadge = document.getElementById('raceBadge');
|
|
const activityTypeSpan = document.getElementById('activityType');
|
|
const activityContent = document.getElementById('activityContent');
|
|
const metricsCard = document.querySelector('#activityContent .card.border');
|
|
|
|
if (activity.race === true) {
|
|
raceBadge.style.display = 'inline-block';
|
|
activityTypeSpan.classList.add('race-activity');
|
|
activityContent.classList.add('race-detail');
|
|
if (metricsCard) {
|
|
metricsCard.classList.add('race-metrics');
|
|
}
|
|
} else {
|
|
raceBadge.style.display = 'none';
|
|
activityTypeSpan.classList.remove('race-activity');
|
|
activityContent.classList.remove('race-detail');
|
|
if (metricsCard) {
|
|
metricsCard.classList.remove('race-metrics');
|
|
}
|
|
}
|
|
|
|
// Indoor badge
|
|
const indoorBadge = document.getElementById('indoorBadge');
|
|
if (activity.indoor === true) {
|
|
indoorBadge.style.display = 'inline-block';
|
|
if (activity.indoorDetectionMethod) {
|
|
indoorBadge.title = `Detected via: ${activity.indoorDetectionMethod}`;
|
|
}
|
|
} else {
|
|
indoorBadge.style.display = 'none';
|
|
}
|
|
|
|
// Description
|
|
if (activity.description) {
|
|
document.getElementById('activityDescription').textContent = activity.description;
|
|
} else {
|
|
document.getElementById('activityDescription').style.display = 'none';
|
|
}
|
|
|
|
// Show Edit/Delete buttons only if user is logged in and owns the activity
|
|
if (FitPubAuth.isAuthenticated()) {
|
|
// Fetch current user to check if they own this activity
|
|
checkActivityOwnership(activity);
|
|
}
|
|
|
|
// Check if activity has GPS track
|
|
const hasGpsTrack = activity.hasGpsTrack === true;
|
|
|
|
// Metrics - Conditional based on GPS availability
|
|
if (hasGpsTrack) {
|
|
// Show GPS-related metrics
|
|
document.getElementById('metricDistance').textContent = formatDistance(activity.totalDistance);
|
|
document.getElementById('metricElevationGain').textContent = activity.elevationGain ? Math.round(activity.elevationGain) + ' m' : 'N/A';
|
|
|
|
// Calculate pace
|
|
if (activity.totalDistance && activity.totalDuration) {
|
|
const paceSeconds = activity.totalDuration / (activity.totalDistance / 1000);
|
|
document.getElementById('metricPace').textContent = formatPace(paceSeconds);
|
|
}
|
|
} else {
|
|
// Hide GPS-related metrics for indoor activities
|
|
document.getElementById('metricDistance').parentElement.parentElement.parentElement.style.display = 'none';
|
|
document.getElementById('metricElevationGain').parentElement.parentElement.parentElement.style.display = 'none';
|
|
document.getElementById('metricPace').parentElement.parentElement.parentElement.style.display = 'none';
|
|
}
|
|
|
|
// Duration is always shown
|
|
document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration);
|
|
|
|
// Moving Time - only show if available and different from total duration
|
|
if (activity.movingTimeSeconds != null && activity.movingTimeSeconds < activity.totalDuration) {
|
|
document.getElementById('metricMovingTimeContainer').style.display = 'block';
|
|
document.getElementById('metricMovingTime').textContent = formatDuration(activity.movingTimeSeconds);
|
|
}
|
|
|
|
// Additional Metrics (conditional)
|
|
// Note: averageSpeed is already in km/h from backend (converted in FitParser)
|
|
if (activity.averageSpeed && hasGpsTrack) {
|
|
document.getElementById('metricAvgSpeedContainer').style.display = 'block';
|
|
document.getElementById('metricAvgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h';
|
|
}
|
|
if (activity.averageHeartRate) {
|
|
document.getElementById('metricAvgHRContainer').style.display = 'block';
|
|
document.getElementById('metricAvgHR').textContent = activity.averageHeartRate + ' bpm';
|
|
}
|
|
if (activity.calories) {
|
|
document.getElementById('metricCaloriesContainer').style.display = 'block';
|
|
document.getElementById('metricCalories').textContent = activity.calories + ' kcal';
|
|
}
|
|
|
|
// Render map or indoor placeholder
|
|
if (hasGpsTrack && activity.trackPoints.length > 0) {
|
|
document.getElementById('mapSection').style.display = 'block';
|
|
document.getElementById('indoorPlaceholder').style.display = 'none';
|
|
renderMap(activity.trackPoints, activity);
|
|
} else {
|
|
// Show indoor activity placeholder
|
|
document.getElementById('mapSection').style.display = 'none';
|
|
document.getElementById('indoorPlaceholder').style.display = 'block';
|
|
showIndoorPlaceholder(activity.activityType);
|
|
}
|
|
|
|
// Load weather data (only for outdoor activities)
|
|
if (hasGpsTrack) {
|
|
loadWeatherData(activity.id);
|
|
}
|
|
|
|
// Render peaks
|
|
if (activity.peaks && activity.peaks.length > 0) {
|
|
const peaksList = document.getElementById('peaksList');
|
|
peaksList.innerHTML = activity.peaks.map(peak => {
|
|
const content = peak.wikipedia
|
|
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
|
|
: peak.name;
|
|
return `<li class="list-group-item">${content}</li>`;
|
|
}).join('');
|
|
document.getElementById('peaksSection').style.display = 'block';
|
|
}
|
|
|
|
// Render elevation chart if data exists
|
|
if (activity.trackPoints && activity.trackPoints.length > 0) {
|
|
// Store track points globally for map marker updates
|
|
currentTrackPoints = activity.trackPoints;
|
|
|
|
const hasElevation = activity.trackPoints.some(p => p.elevation != null);
|
|
if (hasElevation) {
|
|
document.getElementById('elevationSection').style.display = 'block';
|
|
renderElevationChart(activity.trackPoints);
|
|
}
|
|
|
|
// Render heart rate chart if data exists
|
|
const hasHeartRate = activity.trackPoints.some(p => p.heartRate != null && p.heartRate > 0);
|
|
if (hasHeartRate) {
|
|
document.getElementById('heartRateSection').style.display = 'block';
|
|
renderHeartRateChart(activity.trackPoints);
|
|
}
|
|
|
|
// Render speed/pace chart if data exists
|
|
const hasSpeed = activity.trackPoints.some(p => p.speed != null && p.speed > 0);
|
|
if (hasSpeed) {
|
|
document.getElementById('speedSection').style.display = 'block';
|
|
renderSpeedChart(activity.trackPoints);
|
|
}
|
|
|
|
// Show charts section if at least one chart is visible
|
|
if (hasElevation || hasHeartRate || hasSpeed) {
|
|
document.getElementById('chartsSection').style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
// Additional metrics
|
|
renderAdditionalMetrics(activity);
|
|
|
|
// Load social interactions
|
|
loadLikes();
|
|
loadComments();
|
|
setupSocialInteractions(activity);
|
|
}
|
|
|
|
/**
|
|
* Check if the current user owns this activity and show edit/delete buttons if so.
|
|
*/
|
|
async function checkActivityOwnership(activity) {
|
|
try {
|
|
const response = await FitPubAuth.authenticatedFetch('/api/users/me');
|
|
if (response.ok) {
|
|
const currentUser = await response.json();
|
|
if (currentUser.id === activity.userId) {
|
|
document.getElementById('activityActions').style.display = 'block';
|
|
document.getElementById('editBtn').href = `/activities/${activity.id}/edit`;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking activity ownership:', error);
|
|
// Don't show buttons if there's an error
|
|
}
|
|
}
|
|
|
|
async function loadWeatherData(activityId) {
|
|
try {
|
|
const response = await fetch(`/api/activities/${activityId}/weather`);
|
|
if (response.ok) {
|
|
const weather = await response.json();
|
|
displayWeather(weather);
|
|
} else if (response.status === 404) {
|
|
// No weather data available, hide section
|
|
console.debug('No weather data available for activity');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading weather data:', error);
|
|
// Silently fail - weather is optional
|
|
}
|
|
}
|
|
|
|
function displayWeather(weather) {
|
|
// Show weather section
|
|
document.getElementById('weatherSection').style.display = 'block';
|
|
|
|
// Display emoji and condition
|
|
document.getElementById('weatherEmoji').textContent = weather.weatherEmoji || '🌡️';
|
|
document.getElementById('weatherCondition').textContent = weather.weatherDescription || weather.weatherCondition || '--';
|
|
|
|
// Temperature
|
|
if (weather.temperatureCelsius != null) {
|
|
document.getElementById('weatherTemp').textContent = Math.round(weather.temperatureCelsius) + '°C';
|
|
}
|
|
|
|
// Feels like
|
|
if (weather.feelsLikeCelsius != null) {
|
|
document.getElementById('weatherFeelsLike').textContent = Math.round(weather.feelsLikeCelsius) + '°C';
|
|
}
|
|
|
|
// Humidity
|
|
if (weather.humidity != null) {
|
|
document.getElementById('weatherHumidity').textContent = weather.humidity + '%';
|
|
}
|
|
|
|
// Wind
|
|
if (weather.windSpeedKmh != null) {
|
|
const windText = Math.round(weather.windSpeedKmh) + ' km/h';
|
|
const direction = weather.windDirectionCardinal ? ' ' + weather.windDirectionCardinal : '';
|
|
document.getElementById('weatherWind').textContent = windText + direction;
|
|
}
|
|
|
|
// Pressure
|
|
if (weather.pressure != null) {
|
|
document.getElementById('weatherPressure').textContent = weather.pressure + ' hPa';
|
|
}
|
|
|
|
// Visibility
|
|
if (weather.visibilityMeters != null) {
|
|
const visibilityKm = (weather.visibilityMeters / 1000).toFixed(1);
|
|
document.getElementById('weatherVisibility').textContent = visibilityKm + ' km';
|
|
}
|
|
|
|
// Cloudiness
|
|
if (weather.cloudiness != null) {
|
|
document.getElementById('weatherCloudiness').textContent = weather.cloudiness + '%';
|
|
}
|
|
|
|
// Precipitation
|
|
if (weather.precipitationMm != null && weather.precipitationMm > 0) {
|
|
document.getElementById('weatherPrecipSection').style.display = 'block';
|
|
document.getElementById('weatherPrecip').textContent = weather.precipitationMm.toFixed(1) + ' mm';
|
|
}
|
|
}
|
|
|
|
function flattenTrackPoints(trackPoints) {
|
|
return trackPoints.map(coordinates => ([coordinates.longitude, coordinates.latitude]));
|
|
}
|
|
|
|
function renderMap(trackPoints, activity) {
|
|
// Parse GeoJSON from simplifiedTrack
|
|
const geoJson = {
|
|
type: 'LineString',
|
|
coordinates: flattenTrackPoints(trackPoints)
|
|
};
|
|
|
|
// Create map (needs to be done after container is visible)
|
|
setTimeout(() => {
|
|
activityMap = FitPub.createActivityMap('activityMap', geoJson, {
|
|
showStartEnd: false, // Privacy: Do not show start/end markers
|
|
fitBounds: true
|
|
});
|
|
|
|
// Create a hover marker (initially hidden)
|
|
if (activityMap) {
|
|
const pulsingIcon = L.divIcon({
|
|
className: 'chart-hover-marker',
|
|
html: '<div class="marker-pulse"></div>',
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10]
|
|
});
|
|
|
|
hoverMarker = L.marker([0, 0], {
|
|
icon: pulsingIcon,
|
|
opacity: 0
|
|
}).addTo(activityMap);
|
|
|
|
// Add CSS for the pulsing marker
|
|
if (!document.getElementById('chart-hover-marker-style')) {
|
|
const style = document.createElement('style');
|
|
style.id = 'chart-hover-marker-style';
|
|
style.textContent = `
|
|
.chart-hover-marker {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
.marker-pulse {
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: rgba(255, 69, 0, 0.8);
|
|
border: 3px solid white;
|
|
box-shadow: 0 0 10px rgba(255, 69, 0, 0.6);
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { transform: scale(1); opacity: 0.8; }
|
|
50% { transform: scale(1.2); opacity: 1; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
}
|
|
|
|
// Render privacy zones if present (owner view only)
|
|
console.log('Privacy zones in response:', activity.privacyZones);
|
|
if (activity.privacyZones && activity.privacyZones.length > 0) {
|
|
console.log('Rendering', activity.privacyZones.length, 'privacy zones');
|
|
renderPrivacyZones(activityMap, activity.privacyZones);
|
|
} else {
|
|
console.log('No privacy zones to render');
|
|
}
|
|
|
|
// Force fit bounds again after map is fully rendered
|
|
if (activityMap && activityMap.trackLayer) {
|
|
setTimeout(() => {
|
|
try {
|
|
const bounds = activityMap.trackLayer.getBounds();
|
|
if (bounds.isValid()) {
|
|
activityMap.fitBounds(bounds, { padding: [50, 50] });
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not fit bounds on second attempt:', e);
|
|
}
|
|
}, 200);
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
function renderPrivacyZones(map, zones) {
|
|
// Helper to escape HTML
|
|
const escapeHtml = (text) => {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
};
|
|
|
|
// Render each privacy zone as a translucent red circle
|
|
zones.forEach(zone => {
|
|
const circle = L.circle([zone.latitude, zone.longitude], {
|
|
color: '#dc3545',
|
|
fillColor: '#dc3545',
|
|
fillOpacity: 0.1,
|
|
opacity: 0.4,
|
|
weight: 2,
|
|
dashArray: '5, 10',
|
|
radius: zone.radiusMeters
|
|
}).addTo(map);
|
|
|
|
// Add tooltip
|
|
const zoneName = escapeHtml(zone.name || 'Privacy Zone');
|
|
circle.bindTooltip(
|
|
`<strong>🔒 Privacy Zone</strong><br>${zoneName}<br><small class="text-muted">Hidden for others</small>`,
|
|
{
|
|
permanent: false,
|
|
direction: 'top',
|
|
className: 'privacy-zone-tooltip'
|
|
}
|
|
);
|
|
});
|
|
|
|
// Add CSS for privacy zone tooltip
|
|
if (!document.getElementById('privacy-zone-tooltip-style')) {
|
|
const style = document.createElement('style');
|
|
style.id = 'privacy-zone-tooltip-style';
|
|
style.textContent = `
|
|
.privacy-zone-tooltip {
|
|
background: rgba(220, 53, 69, 0.95);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
padding: 8px 12px;
|
|
font-size: 13px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
.privacy-zone-tooltip::before {
|
|
border-top-color: rgba(220, 53, 69, 0.95);
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
}
|
|
|
|
function renderElevationChart(trackPoints) {
|
|
// Calculate cumulative distance and prepare elevation data
|
|
let cumulativeDistance = 0;
|
|
const elevationData = [];
|
|
|
|
for (let i = 0; i < trackPoints.length; i++) {
|
|
const point = trackPoints[i];
|
|
|
|
// Calculate distance from previous point (simple Haversine approximation)
|
|
if (i > 0 && point.latitude && point.longitude) {
|
|
const prev = trackPoints[i - 1];
|
|
if (prev.latitude && prev.longitude) {
|
|
const distance = calculateDistance(
|
|
prev.latitude, prev.longitude,
|
|
point.latitude, point.longitude
|
|
);
|
|
cumulativeDistance += distance;
|
|
}
|
|
}
|
|
|
|
// Add point if it has elevation data
|
|
if (point.elevation != null) {
|
|
elevationData.push({
|
|
distance: cumulativeDistance,
|
|
elevation: point.elevation,
|
|
trackPointIndex: i // Store the original track point index
|
|
});
|
|
}
|
|
}
|
|
|
|
if (elevationData.length > 0) {
|
|
// Smooth elevation data to remove zero/invalid values
|
|
const smoothedData = smoothElevationData(elevationData);
|
|
|
|
// Create throttled hover handler for elevation chart
|
|
const elevationHoverHandler = throttle((event, activeElements) => {
|
|
if (activeElements && activeElements.length > 0) {
|
|
const dataIndex = activeElements[0].index;
|
|
if (smoothedData[dataIndex]) {
|
|
updateMapMarker(smoothedData[dataIndex].trackPointIndex);
|
|
}
|
|
} else {
|
|
hideMapMarker();
|
|
}
|
|
}, 50); // 50ms throttle
|
|
|
|
// Create elevation chart with hover interaction
|
|
const ctx = document.getElementById('elevationChart').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: smoothedData.map(d => (d.distance / 1000).toFixed(2)),
|
|
datasets: [{
|
|
label: 'Elevation (m)',
|
|
data: smoothedData.map(d => d.elevation),
|
|
borderColor: '#10b981',
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 0 // Disable hover radius for better performance
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
onHover: elevationHoverHandler,
|
|
interaction: {
|
|
mode: 'nearest',
|
|
axis: 'x',
|
|
intersect: false
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
title: (context) => {
|
|
return `Distance: ${context[0].label} km`;
|
|
},
|
|
label: (context) => {
|
|
return `Elevation: ${context.parsed.y.toFixed(1)} m`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Distance (km)'
|
|
}
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: 'Elevation (m)'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Smooth elevation data by interpolating zero/invalid values and applying moving average
|
|
* @param {Array} data - Array of {distance, elevation, trackPointIndex} objects
|
|
* @returns {Array} Smoothed elevation data
|
|
*/
|
|
function smoothElevationData(data) {
|
|
if (data.length === 0) return data;
|
|
|
|
// Step 1: Replace zeros and invalid values with interpolated values
|
|
const interpolated = [...data];
|
|
|
|
for (let i = 0; i < interpolated.length; i++) {
|
|
if (interpolated[i].elevation === 0 || interpolated[i].elevation == null) {
|
|
// Find previous valid value
|
|
let prevIndex = i - 1;
|
|
while (prevIndex >= 0 && (interpolated[prevIndex].elevation === 0 || interpolated[prevIndex].elevation == null)) {
|
|
prevIndex--;
|
|
}
|
|
|
|
// Find next valid value
|
|
let nextIndex = i + 1;
|
|
while (nextIndex < interpolated.length && (interpolated[nextIndex].elevation === 0 || interpolated[nextIndex].elevation == null)) {
|
|
nextIndex++;
|
|
}
|
|
|
|
// Interpolate between valid values
|
|
if (prevIndex >= 0 && nextIndex < interpolated.length) {
|
|
const prevElevation = interpolated[prevIndex].elevation;
|
|
const nextElevation = interpolated[nextIndex].elevation;
|
|
const ratio = (i - prevIndex) / (nextIndex - prevIndex);
|
|
interpolated[i].elevation = prevElevation + (nextElevation - prevElevation) * ratio;
|
|
} else if (prevIndex >= 0) {
|
|
// Use previous value if no next value available
|
|
interpolated[i].elevation = interpolated[prevIndex].elevation;
|
|
} else if (nextIndex < interpolated.length) {
|
|
// Use next value if no previous value available
|
|
interpolated[i].elevation = interpolated[nextIndex].elevation;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Apply moving average smoothing (window size 5)
|
|
const windowSize = 5;
|
|
const smoothed = [];
|
|
|
|
for (let i = 0; i < interpolated.length; i++) {
|
|
const start = Math.max(0, i - Math.floor(windowSize / 2));
|
|
const end = Math.min(interpolated.length, i + Math.ceil(windowSize / 2));
|
|
|
|
let sum = 0;
|
|
let count = 0;
|
|
|
|
for (let j = start; j < end; j++) {
|
|
if (interpolated[j].elevation != null && interpolated[j].elevation !== 0) {
|
|
sum += interpolated[j].elevation;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
smoothed.push({
|
|
distance: interpolated[i].distance,
|
|
elevation: count > 0 ? sum / count : interpolated[i].elevation,
|
|
trackPointIndex: interpolated[i].trackPointIndex // Preserve track point index
|
|
});
|
|
}
|
|
|
|
return smoothed;
|
|
}
|
|
|
|
/**
|
|
* Update the hover marker position on the map
|
|
* @param {number} trackPointIndex - Index of the track point to show
|
|
*/
|
|
function updateMapMarker(trackPointIndex) {
|
|
if (!activityMap || !hoverMarker || !currentTrackPoints) return;
|
|
|
|
const point = currentTrackPoints[trackPointIndex];
|
|
if (point && point.latitude != null && point.longitude != null) {
|
|
hoverMarker.setLatLng([point.latitude, point.longitude]);
|
|
hoverMarker.setOpacity(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide the hover marker on the map
|
|
*/
|
|
function hideMapMarker() {
|
|
if (hoverMarker) {
|
|
hoverMarker.setOpacity(0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format elapsed time in minutes to mm:ss or hh:mm:ss
|
|
* @param {number} minutes - Elapsed time in decimal minutes
|
|
* @param {number} totalMinutes - Total duration in minutes (to determine if hours are needed)
|
|
* @returns {string} Formatted time string
|
|
*/
|
|
function formatElapsedTime(minutes, totalMinutes) {
|
|
const totalSeconds = Math.floor(minutes * 60);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
|
const secs = totalSeconds % 60;
|
|
|
|
// Use hh:mm:ss format if total duration is 1 hour or more
|
|
if (totalMinutes >= 60) {
|
|
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
} else {
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render heart rate chart over time
|
|
* @param {Array} trackPoints - Array of track point objects
|
|
*/
|
|
function renderHeartRateChart(trackPoints) {
|
|
// Calculate elapsed time and prepare heart rate data
|
|
const heartRateData = [];
|
|
let startTime = null;
|
|
|
|
for (let i = 0; i < trackPoints.length; i++) {
|
|
const point = trackPoints[i];
|
|
|
|
if (point.heartRate != null && point.heartRate > 0) {
|
|
// Parse timestamp
|
|
const timestamp = new Date(point.timestamp);
|
|
|
|
if (startTime === null) {
|
|
startTime = timestamp;
|
|
}
|
|
|
|
// Calculate elapsed time in minutes
|
|
const elapsedMinutes = (timestamp - startTime) / 1000 / 60;
|
|
|
|
heartRateData.push({
|
|
time: elapsedMinutes,
|
|
heartRate: point.heartRate,
|
|
trackPointIndex: i // Store the original track point index
|
|
});
|
|
}
|
|
}
|
|
|
|
if (heartRateData.length > 0) {
|
|
// Calculate total duration to determine time format
|
|
const totalMinutes = heartRateData[heartRateData.length - 1].time;
|
|
|
|
// Create throttled hover handler for heart rate chart
|
|
const heartRateHoverHandler = throttle((event, activeElements) => {
|
|
if (activeElements && activeElements.length > 0) {
|
|
const dataIndex = activeElements[0].index;
|
|
if (heartRateData[dataIndex]) {
|
|
updateMapMarker(heartRateData[dataIndex].trackPointIndex);
|
|
}
|
|
} else {
|
|
hideMapMarker();
|
|
}
|
|
}, 50); // 50ms throttle
|
|
|
|
// Create heart rate chart using Chart.js
|
|
const ctx = document.getElementById('heartRateChart').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: heartRateData.map(d => formatElapsedTime(d.time, totalMinutes)),
|
|
datasets: [{
|
|
label: 'Heart Rate (bpm)',
|
|
data: heartRateData.map(d => d.heartRate),
|
|
borderColor: 'rgb(220, 53, 69)',
|
|
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 0 // Disable hover radius for better performance
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
onHover: heartRateHoverHandler,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
callbacks: {
|
|
title: function(context) {
|
|
return 'Time: ' + context[0].label;
|
|
},
|
|
label: function(context) {
|
|
return context.parsed.y + ' bpm';
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Time'
|
|
},
|
|
ticks: {
|
|
maxTicksLimit: 10
|
|
}
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: 'Heart Rate (bpm)'
|
|
},
|
|
beginAtZero: false
|
|
}
|
|
},
|
|
interaction: {
|
|
mode: 'nearest',
|
|
axis: 'x',
|
|
intersect: false
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render speed/pace chart over time
|
|
* @param {Array} trackPoints - Array of track point objects
|
|
*/
|
|
function renderSpeedChart(trackPoints) {
|
|
// Calculate elapsed time and prepare speed data
|
|
const speedData = [];
|
|
let startTime = null;
|
|
|
|
for (let i = 0; i < trackPoints.length; i++) {
|
|
const point = trackPoints[i];
|
|
|
|
if (point.speed != null && point.speed > 0) {
|
|
// Parse timestamp
|
|
const timestamp = new Date(point.timestamp);
|
|
|
|
if (startTime === null) {
|
|
startTime = timestamp;
|
|
}
|
|
|
|
// Calculate elapsed time in minutes
|
|
const elapsedMinutes = (timestamp - startTime) / 1000 / 60;
|
|
|
|
// Speed is already in km/h from the FIT parser (converted during parsing)
|
|
const speedKmh = point.speed;
|
|
|
|
speedData.push({
|
|
time: elapsedMinutes,
|
|
speed: speedKmh,
|
|
trackPointIndex: i // Store the original track point index
|
|
});
|
|
}
|
|
}
|
|
|
|
if (speedData.length > 0) {
|
|
// Apply moving average smoothing to speed data (window size 5)
|
|
const smoothedSpeedData = smoothSpeedData(speedData);
|
|
|
|
// Calculate total duration to determine time format
|
|
const totalMinutes = smoothedSpeedData[smoothedSpeedData.length - 1].time;
|
|
|
|
// Create throttled hover handler for speed chart
|
|
const speedHoverHandler = throttle((event, activeElements) => {
|
|
if (activeElements && activeElements.length > 0) {
|
|
const dataIndex = activeElements[0].index;
|
|
if (smoothedSpeedData[dataIndex]) {
|
|
updateMapMarker(smoothedSpeedData[dataIndex].trackPointIndex);
|
|
}
|
|
} else {
|
|
hideMapMarker();
|
|
}
|
|
}, 50); // 50ms throttle
|
|
|
|
// Create speed chart using Chart.js
|
|
const ctx = document.getElementById('speedChart').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: smoothedSpeedData.map(d => formatElapsedTime(d.time, totalMinutes)),
|
|
datasets: [{
|
|
label: 'Speed (km/h)',
|
|
data: smoothedSpeedData.map(d => d.speed),
|
|
borderColor: 'rgb(13, 110, 253)',
|
|
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 0,
|
|
pointHoverRadius: 0 // Disable hover radius for better performance
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
onHover: speedHoverHandler,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
callbacks: {
|
|
title: function(context) {
|
|
return 'Time: ' + context[0].label;
|
|
},
|
|
label: function(context) {
|
|
const speedKmh = context.parsed.y;
|
|
// Calculate pace (min/km)
|
|
const paceMinPerKm = speedKmh > 0 ? 60 / speedKmh : 0;
|
|
const paceMin = Math.floor(paceMinPerKm);
|
|
const paceSec = Math.round((paceMinPerKm - paceMin) * 60);
|
|
return `${speedKmh.toFixed(1)} km/h (${paceMin}:${paceSec.toString().padStart(2, '0')} /km)`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text: 'Time'
|
|
},
|
|
ticks: {
|
|
maxTicksLimit: 10
|
|
}
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: 'Speed (km/h)'
|
|
},
|
|
beginAtZero: true
|
|
}
|
|
},
|
|
interaction: {
|
|
mode: 'nearest',
|
|
axis: 'x',
|
|
intersect: false
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Smooth speed data by applying moving average
|
|
* @param {Array} data - Array of {time, speed, trackPointIndex} objects
|
|
* @returns {Array} Smoothed speed data
|
|
*/
|
|
function smoothSpeedData(data) {
|
|
if (data.length === 0) return data;
|
|
|
|
const windowSize = 5;
|
|
const smoothed = [];
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const start = Math.max(0, i - Math.floor(windowSize / 2));
|
|
const end = Math.min(data.length, i + Math.ceil(windowSize / 2));
|
|
|
|
let sum = 0;
|
|
let count = 0;
|
|
|
|
for (let j = start; j < end; j++) {
|
|
if (data[j].speed > 0) {
|
|
sum += data[j].speed;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
smoothed.push({
|
|
time: data[i].time,
|
|
speed: count > 0 ? sum / count : data[i].speed,
|
|
trackPointIndex: data[i].trackPointIndex // Preserve track point index
|
|
});
|
|
}
|
|
|
|
return smoothed;
|
|
}
|
|
|
|
// Haversine formula to calculate distance between two GPS points
|
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371000; // Earth's radius in meters
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
|
|
function renderAdditionalMetrics(activity) {
|
|
let hasAdditionalMetrics = false;
|
|
|
|
// Average Heart Rate
|
|
if (activity.averageHeartRate) {
|
|
document.getElementById('avgHeartRate').textContent = Math.round(activity.averageHeartRate) + ' bpm';
|
|
document.getElementById('avgHeartRateContainer').style.display = 'block';
|
|
hasAdditionalMetrics = true;
|
|
}
|
|
|
|
// Max Heart Rate
|
|
if (activity.maxHeartRate) {
|
|
document.getElementById('maxHeartRate').textContent = Math.round(activity.maxHeartRate) + ' bpm';
|
|
document.getElementById('maxHeartRateContainer').style.display = 'block';
|
|
hasAdditionalMetrics = true;
|
|
}
|
|
|
|
// Average Cadence
|
|
if (activity.averageCadence) {
|
|
document.getElementById('avgCadence').textContent = Math.round(activity.averageCadence) + ' rpm';
|
|
document.getElementById('avgCadenceContainer').style.display = 'block';
|
|
hasAdditionalMetrics = true;
|
|
}
|
|
|
|
// Average Speed (already in km/h from backend)
|
|
if (activity.averageSpeed) {
|
|
document.getElementById('avgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h';
|
|
document.getElementById('avgSpeedContainer').style.display = 'block';
|
|
hasAdditionalMetrics = true;
|
|
}
|
|
|
|
// Max Speed (already in km/h from backend)
|
|
if (activity.maxSpeed) {
|
|
document.getElementById('maxSpeed').textContent = parseFloat(activity.maxSpeed).toFixed(1) + ' km/h';
|
|
document.getElementById('maxSpeedContainer').style.display = 'block';
|
|
hasAdditionalMetrics = true;
|
|
}
|
|
|
|
// Calories
|
|
if (activity.calories) {
|
|
document.getElementById('calories').textContent = Math.round(activity.calories) + ' kcal';
|
|
document.getElementById('caloriesContainer').style.display = 'block';
|
|
hasAdditionalMetrics = true;
|
|
}
|
|
|
|
if (hasAdditionalMetrics) {
|
|
document.getElementById('additionalMetrics').style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Social interactions functionality
|
|
async function loadLikes() {
|
|
try {
|
|
const response = await fetch(`/api/activities/${activityId}/likes`);
|
|
if (response.ok) {
|
|
const likes = await response.json();
|
|
renderLikes(likes);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading likes:', error);
|
|
}
|
|
}
|
|
|
|
function renderLikes(likes) {
|
|
const likesList = document.getElementById('likesList');
|
|
const likeCount = document.getElementById('likeCount');
|
|
const likesCountText = document.getElementById('likesCountText');
|
|
|
|
likeCount.textContent = likes.length;
|
|
likesCountText.textContent = likes.length;
|
|
|
|
if (likes.length === 0) {
|
|
likesList.innerHTML = '<span class="text-muted">No likes yet</span>';
|
|
return;
|
|
}
|
|
|
|
likesList.innerHTML = likes.map(like => {
|
|
const displayName = like.displayName || like.username || 'Unknown';
|
|
const avatarHtml = like.avatarUrl
|
|
? `<img src="${like.avatarUrl}" alt="${displayName}" class="rounded-circle me-2" width="32" height="32">`
|
|
: `<i class="bi bi-person-circle me-2" style="font-size: 32px;"></i>`;
|
|
|
|
return `
|
|
<div class="d-flex align-items-center p-2 border rounded">
|
|
${avatarHtml}
|
|
<span>${displayName}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function loadComments() {
|
|
try {
|
|
const response = await fetch(`/api/activities/${activityId}/comments`);
|
|
if (response.ok) {
|
|
const commentsPage = await response.json();
|
|
renderComments(commentsPage.content || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading comments:', error);
|
|
}
|
|
}
|
|
|
|
function renderComments(comments) {
|
|
const commentsList = document.getElementById('commentsList');
|
|
const commentsCount = document.getElementById('commentsCount');
|
|
|
|
commentsCount.textContent = comments.length;
|
|
|
|
if (comments.length === 0) {
|
|
commentsList.innerHTML = '<p class="text-muted">No comments yet. Be the first to comment!</p>';
|
|
return;
|
|
}
|
|
|
|
commentsList.innerHTML = comments.map(comment => {
|
|
const displayName = comment.displayName || comment.username || 'Unknown';
|
|
const avatarHtml = comment.avatarUrl
|
|
? `<img src="${comment.avatarUrl}" alt="${displayName}" class="rounded-circle" width="40" height="40">`
|
|
: `<i class="bi bi-person-circle" style="font-size: 40px;"></i>`;
|
|
|
|
const createdAt = new Date(comment.createdAt).toLocaleString();
|
|
const deleteBtn = comment.canDelete
|
|
? `<button class="btn btn-sm btn-outline-danger delete-comment-btn" data-comment-id="${comment.id}">
|
|
<i class="bi bi-trash"></i>
|
|
</button>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="d-flex mb-3 pb-3 border-bottom" data-comment-id="${comment.id}">
|
|
<div class="me-3">
|
|
${avatarHtml}
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<strong>${displayName}</strong>
|
|
<small class="text-muted ms-2">${createdAt}</small>
|
|
</div>
|
|
${deleteBtn}
|
|
</div>
|
|
<p class="mb-0 mt-1">${escapeHtml(comment.content)}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Add delete event listeners
|
|
document.querySelectorAll('.delete-comment-btn').forEach(btn => {
|
|
btn.addEventListener('click', async function() {
|
|
if (confirm('Delete this comment?')) {
|
|
await deleteComment(this.dataset.commentId);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupSocialInteractions(activity) {
|
|
const isAuthenticated = FitPubAuth.isAuthenticated();
|
|
|
|
if (isAuthenticated) {
|
|
// Show comment form for authenticated users
|
|
document.getElementById('commentForm').style.display = 'block';
|
|
|
|
// Update like button based on activity data
|
|
if (activity.likedByCurrentUser) {
|
|
updateLikeButton(true);
|
|
}
|
|
|
|
// Setup like button click handler
|
|
document.getElementById('likeBtn').addEventListener('click', handleLikeClick);
|
|
|
|
// Show comment form for authenticated users
|
|
document.getElementById('commentForm').style.display = 'block';
|
|
|
|
// Setup comment form submit handler
|
|
document.getElementById('addCommentForm').addEventListener('submit', handleCommentSubmit);
|
|
} else {
|
|
// Show login prompt for non-authenticated users
|
|
document.getElementById('loginPrompt').style.display = 'block';
|
|
// Hide like button for non-authenticated users
|
|
document.getElementById('likeBtn').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function handleLikeClick(event) {
|
|
event.preventDefault();
|
|
const btn = event.currentTarget;
|
|
const isLiked = btn.classList.contains('btn-danger');
|
|
|
|
try {
|
|
if (isLiked) {
|
|
// Unlike
|
|
const response = await FitPubAuth.authenticatedFetch(
|
|
`/api/activities/${activityId}/likes`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
|
|
if (response.ok) {
|
|
updateLikeButton(false);
|
|
loadLikes(); // Reload likes list
|
|
}
|
|
} else {
|
|
// Like
|
|
const response = await FitPubAuth.authenticatedFetch(
|
|
`/api/activities/${activityId}/likes`,
|
|
{ method: 'POST' }
|
|
);
|
|
|
|
if (response.ok) {
|
|
updateLikeButton(true);
|
|
loadLikes(); // Reload likes list
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling like:', error);
|
|
FitPub.showAlert('Failed to update like. Please try again.', 'danger');
|
|
}
|
|
}
|
|
|
|
function updateLikeButton(isLiked) {
|
|
const btn = document.getElementById('likeBtn');
|
|
const btnText = document.getElementById('likeBtnText');
|
|
const icon = btn.querySelector('i');
|
|
|
|
if (isLiked) {
|
|
btn.classList.remove('btn-outline-danger');
|
|
btn.classList.add('btn-danger');
|
|
icon.classList.remove('bi-heart');
|
|
icon.classList.add('bi-heart-fill');
|
|
btnText.textContent = 'Liked';
|
|
} else {
|
|
btn.classList.remove('btn-danger');
|
|
btn.classList.add('btn-outline-danger');
|
|
icon.classList.remove('bi-heart-fill');
|
|
icon.classList.add('bi-heart');
|
|
btnText.textContent = 'Like';
|
|
}
|
|
}
|
|
|
|
async function handleCommentSubmit(event) {
|
|
event.preventDefault();
|
|
|
|
const contentInput = document.getElementById('commentContent');
|
|
const content = contentInput.value.trim();
|
|
|
|
if (!content) return;
|
|
|
|
try {
|
|
const response = await FitPubAuth.authenticatedFetch(
|
|
`/api/activities/${activityId}/comments`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content })
|
|
}
|
|
);
|
|
|
|
if (response.ok) {
|
|
contentInput.value = '';
|
|
loadComments(); // Reload comments list
|
|
FitPub.showAlert('Comment posted successfully', 'success');
|
|
} else {
|
|
throw new Error('Failed to post comment');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error posting comment:', error);
|
|
FitPub.showAlert('Failed to post comment. Please try again.', 'danger');
|
|
}
|
|
}
|
|
|
|
async function deleteComment(commentId) {
|
|
try {
|
|
const response = await FitPubAuth.authenticatedFetch(
|
|
`/api/activities/${activityId}/comments/${commentId}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
|
|
if (response.ok) {
|
|
loadComments(); // Reload comments list
|
|
FitPub.showAlert('Comment deleted successfully', 'success');
|
|
} else {
|
|
throw new Error('Failed to delete comment');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting comment:', error);
|
|
FitPub.showAlert('Failed to delete comment. Please try again.', 'danger');
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Delete functionality
|
|
document.getElementById('deleteBtn').addEventListener('click', function() {
|
|
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
modal.show();
|
|
});
|
|
|
|
document.getElementById('confirmDeleteBtn').addEventListener('click', async function() {
|
|
try {
|
|
const response = await FitPubAuth.authenticatedFetch(
|
|
`/api/activities/${activityId}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
|
|
if (response.ok) {
|
|
// Close modal and redirect
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
|
|
modal.hide();
|
|
|
|
FitPub.showAlert('Activity deleted successfully', 'success');
|
|
|
|
setTimeout(() => {
|
|
window.location.href = '/activities';
|
|
}, 1000);
|
|
} else {
|
|
throw new Error('Failed to delete activity');
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
FitPub.showAlert('Failed to delete activity. Please try again.', 'danger');
|
|
}
|
|
});
|
|
|
|
// Helper functions
|
|
function formatDistance(meters) {
|
|
if (!meters) return 'N/A';
|
|
if (meters >= 1000) {
|
|
return (meters / 1000).toFixed(2) + ' km';
|
|
}
|
|
return Math.round(meters) + ' m';
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (!seconds) return 'N/A';
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
const parts = [];
|
|
if (hours > 0) parts.push(hours + 'h');
|
|
if (minutes > 0) parts.push(minutes + 'm');
|
|
if (secs > 0 || parts.length === 0) parts.push(secs + 's');
|
|
|
|
return parts.join(' ');
|
|
}
|
|
|
|
function formatPace(secondsPerKm) {
|
|
if (!secondsPerKm) return 'N/A';
|
|
const minutes = Math.floor(secondsPerKm / 60);
|
|
const seconds = Math.floor(secondsPerKm % 60);
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}/km`;
|
|
}
|
|
|
|
function getVisibilityIcon(visibility) {
|
|
switch (visibility) {
|
|
case 'PUBLIC': return 'globe';
|
|
case 'FOLLOWERS': return 'people';
|
|
case 'PRIVATE': return 'lock';
|
|
default: return 'question-circle';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show indoor activity placeholder with appropriate emoji
|
|
*/
|
|
function showIndoorPlaceholder(activityType) {
|
|
const emojiMap = {
|
|
'RUN': '🏃',
|
|
'RIDE': '🚴',
|
|
'CYCLING': '🚴',
|
|
'INDOOR_CYCLING': '🚴',
|
|
'HIKE': '🥾',
|
|
'WALK': '🚶',
|
|
'SWIM': '🏊',
|
|
'WORKOUT': '💪',
|
|
'YOGA': '🧘',
|
|
'ALPINE_SKI': '⛷️',
|
|
'NORDIC_SKI': '⛷️',
|
|
'SNOWBOARD': '🏂',
|
|
'ROWING': '🚣',
|
|
'KAYAKING': '🛶',
|
|
'CANOEING': '🛶',
|
|
'ROCK_CLIMBING': '🧗',
|
|
'MOUNTAINEERING': '⛰️',
|
|
'OTHER': '🏋️'
|
|
};
|
|
|
|
const nameMap = {
|
|
'RUN': 'Indoor Running',
|
|
'RIDE': 'Indoor Cycling',
|
|
'CYCLING': 'Indoor Cycling',
|
|
'INDOOR_CYCLING': 'Indoor Cycling',
|
|
'HIKE': 'Indoor Activity',
|
|
'WALK': 'Indoor Walking',
|
|
'SWIM': 'Indoor Swimming',
|
|
'WORKOUT': 'Workout',
|
|
'YOGA': 'Yoga',
|
|
'ALPINE_SKI': 'Skiing',
|
|
'NORDIC_SKI': 'Cross-Country Skiing',
|
|
'SNOWBOARD': 'Snowboarding',
|
|
'ROWING': 'Indoor Rowing',
|
|
'KAYAKING': 'Kayaking',
|
|
'CANOEING': 'Canoeing',
|
|
'ROCK_CLIMBING': 'Climbing',
|
|
'MOUNTAINEERING': 'Mountaineering',
|
|
'OTHER': 'Indoor Activity'
|
|
};
|
|
|
|
const emoji = emojiMap[activityType] || '🏋️';
|
|
const name = nameMap[activityType] || 'Indoor Activity';
|
|
|
|
document.getElementById('activityTypeEmoji').textContent = emoji;
|
|
document.getElementById('activityTypeName').textContent = name;
|
|
}
|
|
});
|
|
</script>
|
|
</th:block>
|
|
</body>
|
|
</html>
|