Nice things

This commit is contained in:
Tim Zöller 2025-12-04 12:44:18 +01:00
parent 6dccf87aec
commit 362680f774
27 changed files with 3833 additions and 4 deletions

View file

@ -0,0 +1,90 @@
-- Personal Records Table
CREATE TABLE personal_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_type VARCHAR(50) NOT NULL,
record_type VARCHAR(50) NOT NULL, -- FASTEST_1K, FASTEST_5K, FASTEST_10K, FASTEST_HALF_MARATHON, FASTEST_MARATHON, LONGEST_DISTANCE, LONGEST_DURATION, HIGHEST_ELEVATION_GAIN, MAX_SPEED
value DECIMAL(10, 2) NOT NULL,
unit VARCHAR(20) NOT NULL, -- seconds, meters, meters/second
activity_id UUID REFERENCES activities(id) ON DELETE SET NULL,
achieved_at TIMESTAMP NOT NULL,
previous_value DECIMAL(10, 2),
previous_achieved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, activity_type, record_type)
);
CREATE INDEX idx_personal_records_user ON personal_records(user_id);
CREATE INDEX idx_personal_records_type ON personal_records(activity_type, record_type);
CREATE INDEX idx_personal_records_achieved ON personal_records(achieved_at DESC);
-- Achievements/Badges Table
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
achievement_type VARCHAR(100) NOT NULL, -- FIRST_ACTIVITY, DISTANCE_10K, DISTANCE_100K, DISTANCE_1000K, STREAK_7, STREAK_30, EARLY_BIRD, NIGHT_OWL, MOUNTAINEER, EXPLORER, CONSISTENT_WEEK, etc.
name VARCHAR(100) NOT NULL,
description TEXT,
badge_icon VARCHAR(50), -- emoji or icon class
badge_color VARCHAR(20),
earned_at TIMESTAMP NOT NULL DEFAULT NOW(),
activity_id UUID REFERENCES activities(id) ON DELETE SET NULL,
metadata JSONB, -- Additional data like distance reached, streak count, etc.
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, achievement_type)
);
CREATE INDEX idx_achievements_user ON achievements(user_id);
CREATE INDEX idx_achievements_earned ON achievements(earned_at DESC);
CREATE INDEX idx_achievements_type ON achievements(achievement_type);
-- Training Load Table (for calculating training stress and recovery)
CREATE TABLE training_load (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
activity_count INTEGER DEFAULT 0,
total_duration_seconds BIGINT DEFAULT 0,
total_distance_meters DECIMAL(10, 2) DEFAULT 0,
total_elevation_gain_meters DECIMAL(10, 2) DEFAULT 0,
training_stress_score DECIMAL(6, 2), -- Calculated training load
acute_training_load DECIMAL(6, 2), -- 7-day rolling average
chronic_training_load DECIMAL(6, 2), -- 28-day rolling average
training_stress_balance DECIMAL(6, 2), -- ATL - CTL (positive = fresh, negative = fatigued)
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, date)
);
CREATE INDEX idx_training_load_user_date ON training_load(user_id, date DESC);
CREATE INDEX idx_training_load_date ON training_load(date DESC);
-- Weekly/Monthly Summaries Table
CREATE TABLE activity_summaries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_type VARCHAR(20) NOT NULL, -- WEEK, MONTH, YEAR
period_start DATE NOT NULL,
period_end DATE NOT NULL,
activity_count INTEGER DEFAULT 0,
total_duration_seconds BIGINT DEFAULT 0,
total_distance_meters DECIMAL(10, 2) DEFAULT 0,
total_elevation_gain_meters DECIMAL(10, 2) DEFAULT 0,
avg_speed_mps DECIMAL(6, 2),
max_speed_mps DECIMAL(6, 2),
activity_type_breakdown JSONB, -- {"Run": 5, "Ride": 3, "Hike": 2}
personal_records_set INTEGER DEFAULT 0,
achievements_earned INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, period_type, period_start)
);
CREATE INDEX idx_activity_summaries_user ON activity_summaries(user_id);
CREATE INDEX idx_activity_summaries_period ON activity_summaries(user_id, period_type, period_start DESC);
COMMENT ON TABLE personal_records IS 'Tracks personal records (PRs) for various metrics across different activity types';
COMMENT ON TABLE achievements IS 'Gamification badges earned by users for various accomplishments';
COMMENT ON TABLE training_load IS 'Daily training load metrics for tracking fitness and fatigue';
COMMENT ON TABLE activity_summaries IS 'Pre-calculated weekly/monthly/yearly activity summaries for performance';

View file

@ -216,6 +216,7 @@ const FitPubAuth = {
const usernameDisplay = document.getElementById('usernameDisplay');
const myActivitiesLink = document.getElementById('myActivitiesLink');
const uploadLink = document.getElementById('uploadLink');
const analyticsLink = document.getElementById('analyticsLink');
const notificationsBell = document.getElementById('notificationsBell');
if (this.isAuthenticated()) {
@ -236,6 +237,10 @@ const FitPubAuth = {
uploadLink.style.display = '';
uploadLink.parentElement.style.display = '';
}
if (analyticsLink) {
analyticsLink.style.display = '';
analyticsLink.parentElement.style.display = '';
}
// Show notifications bell
if (notificationsBell) {
@ -271,6 +276,10 @@ const FitPubAuth = {
uploadLink.style.display = 'none';
uploadLink.parentElement.style.display = 'none';
}
if (analyticsLink) {
analyticsLink.style.display = 'none';
analyticsLink.parentElement.style.display = 'none';
}
}
},

View file

@ -0,0 +1,267 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout :: layout(~{::title}, ~{::content})}">
<head>
<title>Achievements - FitPub</title>
</head>
<body>
<div th:fragment="content">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-award-fill" style="color: var(--accent-orange);"></i> Achievements
</h1>
<a href="/analytics" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
<!-- Stats Summary -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h2 class="mb-0" id="earned-count">0</h2>
<p class="text-muted mb-0">Achievements Earned</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h2 class="mb-0" id="latest-date">-</h2>
<p class="text-muted mb-0">Latest Achievement</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h2 class="mb-0" id="completion-percent">0%</h2>
<p class="text-muted mb-0">Collection Progress</p>
</div>
</div>
</div>
</div>
<!-- Loading Spinner -->
<div id="loading-spinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading your achievements...</p>
</div>
<!-- Achievements Grid -->
<div id="achievements-content" style="display: none;">
<div id="achievements-grid" class="row g-4"></div>
<!-- Empty State -->
<div id="empty-state" style="display: none;" class="empty-state empty-state-activities">
<div class="empty-state-icon">🏆</div>
<h3 class="empty-state-title">No Achievements Yet</h3>
<p class="empty-state-message">Complete activities to earn badges and achievements!</p>
<div class="empty-state-action">
<a href="/activities/upload" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Activity
</a>
</div>
</div>
</div>
<!-- Error Message -->
<div id="error-message" style="display: none;" class="alert alert-danger"></div>
</div>
<script>
const TOTAL_ACHIEVEMENTS = 25; // Total possible achievements
async function loadAchievements() {
try {
const response = await FitPubAuth.authenticatedFetch('/api/analytics/achievements');
if (!response.ok) {
throw new Error('Failed to load achievements');
}
const achievements = await response.json();
displayAchievements(achievements);
updateStats(achievements);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('achievements-content').style.display = 'block';
} catch (error) {
console.error('Error loading achievements:', error);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('error-message').textContent = 'Failed to load achievements';
document.getElementById('error-message').style.display = 'block';
}
}
function updateStats(achievements) {
// Earned count
document.getElementById('earned-count').textContent = achievements.length;
// Latest achievement date
if (achievements.length > 0) {
const latest = new Date(achievements[0].earnedAt);
document.getElementById('latest-date').textContent = latest.toLocaleDateString();
}
// Completion percentage
const percent = ((achievements.length / TOTAL_ACHIEVEMENTS) * 100).toFixed(0);
document.getElementById('completion-percent').textContent = percent + '%';
}
function displayAchievements(achievements) {
const grid = document.getElementById('achievements-grid');
const emptyState = document.getElementById('empty-state');
if (achievements.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
grid.style.display = 'flex';
emptyState.style.display = 'none';
const html = achievements.map(ach => {
const date = new Date(ach.earnedAt).toLocaleDateString();
const timeAgo = getTimeAgo(ach.earnedAt);
const metadataHtml = formatMetadata(ach.metadata);
return `
<div class="col-md-6 col-lg-4">
<div class="card h-100 achievement-card" style="border-color: ${ach.badgeColor || 'var(--primary-color)'};">
<div class="card-body">
<div class="text-center mb-3">
<div class="achievement-icon" style="font-size: 4rem;">
${ach.badgeIcon || '🏆'}
</div>
</div>
<h5 class="card-title text-center mb-2">${ach.name}</h5>
<p class="card-text text-center text-muted">${ach.description}</p>
${metadataHtml}
<div class="text-center mt-3">
<small class="text-muted">
<i class="bi bi-calendar"></i> ${date}
<br>
<i class="bi bi-clock"></i> ${timeAgo}
</small>
</div>
${ach.activityId ? `
<a href="/activities/detail/${ach.activityId}" class="btn btn-sm btn-outline-primary mt-3 w-100">
View Activity <i class="bi bi-arrow-right"></i>
</a>
` : ''}
</div>
</div>
</div>
`;
}).join('');
grid.innerHTML = html;
}
function formatMetadata(metadata) {
if (!metadata) return '';
let html = '<div class="achievement-metadata mt-2">';
if (metadata.distance_km) {
html += `<div class="text-center"><strong>${metadata.distance_km} km</strong> total distance</div>`;
}
if (metadata.activity_count) {
html += `<div class="text-center"><strong>${metadata.activity_count}</strong> activities completed</div>`;
}
if (metadata.streak_days) {
html += `<div class="text-center"><strong>${metadata.streak_days} days</strong> in a row</div>`;
}
if (metadata.max_speed_kmh) {
html += `<div class="text-center"><strong>${metadata.max_speed_kmh.toFixed(2)} km/h</strong> max speed</div>`;
}
if (metadata.elevation_gain) {
html += `<div class="text-center"><strong>${metadata.elevation_gain} m</strong> elevation</div>`;
}
if (metadata.total_elevation) {
html += `<div class="text-center"><strong>${metadata.total_elevation.toFixed(0)} m</strong> total elevation</div>`;
}
if (metadata.activity_types) {
html += `<div class="text-center"><strong>${metadata.activity_types}</strong> activity types</div>`;
}
html += '</div>';
return html;
}
function getTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
}
// Load on page load
document.addEventListener('DOMContentLoaded', () => {
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/auth/login';
return;
}
loadAchievements();
});
</script>
<style>
.achievement-card {
transition: all 0.3s ease;
border: 3px solid;
animation: fadeInUp 0.5s ease-out;
}
.achievement-card:hover {
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(255, 0, 255, 0.3);
}
.achievement-icon {
animation: bounce 2s ease-in-out infinite;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.achievement-metadata {
background: var(--light-color);
padding: 0.5rem;
border-radius: var(--border-radius);
font-size: 0.875rem;
}
</style>
</div>
</body>
</html>

View file

@ -0,0 +1,349 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout :: layout(~{::title}, ~{::content})}">
<head>
<title>Analytics Dashboard - FitPub</title>
</head>
<body>
<div th:fragment="content">
<div class="container py-4">
<h1 class="mb-4">
<i class="bi bi-graph-up"></i> Analytics Dashboard
</h1>
<!-- Loading Spinner -->
<div id="loading-spinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading your analytics...</p>
</div>
<!-- Dashboard Content -->
<div id="dashboard-content" style="display: none;">
<!-- Stats Overview Row -->
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body text-center">
<i class="bi bi-trophy-fill text-warning" style="font-size: 2rem;"></i>
<h3 class="mt-2 mb-0" id="pr-count">0</h3>
<p class="text-muted mb-0">Personal Records</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body text-center">
<i class="bi bi-award-fill" style="font-size: 2rem; color: var(--accent-orange);"></i>
<h3 class="mt-2 mb-0" id="achievement-count">0</h3>
<p class="text-muted mb-0">Achievements</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body text-center">
<i class="bi bi-activity" style="font-size: 2rem; color: var(--secondary-color);"></i>
<h3 class="mt-2 mb-0" id="form-status">-</h3>
<p class="text-muted mb-0">Form Status</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body text-center">
<i class="bi bi-calendar-week-fill" style="font-size: 2rem; color: var(--accent-lime);"></i>
<h3 class="mt-2 mb-0" id="week-activities">0</h3>
<p class="text-muted mb-0">This Week</p>
</div>
</div>
</div>
</div>
<!-- Current Week & Month Row -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-calendar-week"></i> Current Week</h5>
</div>
<div class="card-body" id="current-week-summary">
<p class="text-muted">No data for current week</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-calendar-month"></i> Current Month</h5>
</div>
<div class="card-body" id="current-month-summary">
<p class="text-muted">No data for current month</p>
</div>
</div>
</div>
</div>
<!-- Recent Personal Records -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-trophy"></i> Recent Personal Records</h5>
<a href="/analytics/personal-records" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="recent-prs-list"></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-award"></i> Recent Achievements</h5>
<a href="/analytics/achievements" class="btn btn-sm btn-outline-primary">View All</a>
</div>
<div class="card-body">
<div id="recent-achievements-list"></div>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="row g-4">
<div class="col-md-3">
<a href="/analytics/personal-records" class="card analytics-link-card text-decoration-none">
<div class="card-body text-center">
<i class="bi bi-trophy-fill" style="font-size: 3rem; color: var(--warning-color);"></i>
<h5 class="mt-3">Personal Records</h5>
</div>
</a>
</div>
<div class="col-md-3">
<a href="/analytics/achievements" class="card analytics-link-card text-decoration-none">
<div class="card-body text-center">
<i class="bi bi-award-fill" style="font-size: 3rem; color: var(--accent-orange);"></i>
<h5 class="mt-3">Achievements</h5>
</div>
</a>
</div>
<div class="col-md-3">
<a href="/analytics/training-load" class="card analytics-link-card text-decoration-none">
<div class="card-body text-center">
<i class="bi bi-graph-up" style="font-size: 3rem; color: var(--secondary-color);"></i>
<h5 class="mt-3">Training Load</h5>
</div>
</a>
</div>
<div class="col-md-3">
<a href="/analytics/summaries" class="card analytics-link-card text-decoration-none">
<div class="card-body text-center">
<i class="bi bi-calendar-range" style="font-size: 3rem; color: var(--accent-lime);"></i>
<h5 class="mt-3">Summaries</h5>
</div>
</a>
</div>
</div>
</div>
<!-- Error Message -->
<div id="error-message" style="display: none;" class="alert alert-danger"></div>
</div>
<script>
// Load analytics dashboard
async function loadDashboard() {
try {
const response = await FitPubAuth.authenticatedFetch('/api/analytics/dashboard');
if (!response.ok) {
throw new Error('Failed to load analytics');
}
const data = await response.json();
displayDashboard(data);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('dashboard-content').style.display = 'block';
} catch (error) {
console.error('Error loading dashboard:', error);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('error-message').textContent = 'Failed to load analytics dashboard';
document.getElementById('error-message').style.display = 'block';
}
}
function displayDashboard(data) {
// Stats
document.getElementById('pr-count').textContent = data.personalRecordsCount || 0;
document.getElementById('achievement-count').textContent = data.achievementsCount || 0;
document.getElementById('form-status').textContent = formatFormStatus(data.formStatus);
document.getElementById('week-activities').textContent = data.currentWeekSummary?.activityCount || 0;
// Current week summary
if (data.currentWeekSummary) {
displaySummary('current-week-summary', data.currentWeekSummary);
}
// Current month summary
if (data.currentMonthSummary) {
displaySummary('current-month-summary', data.currentMonthSummary);
}
// Recent PRs
displayRecentPRs(data.recentPersonalRecords || []);
// Recent achievements
displayRecentAchievements(data.recentAchievements || []);
}
function formatFormStatus(status) {
const statusMap = {
'FRESH': '😊 Fresh',
'OPTIMAL': '💪 Optimal',
'FATIGUED': '😴 Fatigued',
'UNKNOWN': '❓ Unknown'
};
return statusMap[status] || status;
}
function displaySummary(elementId, summary) {
const distanceKm = (summary.totalDistanceMeters / 1000).toFixed(2);
const durationHours = (summary.totalDurationSeconds / 3600).toFixed(1);
const elevationM = summary.totalElevationGainMeters?.toFixed(0) || 0;
const html = `
<div class="row g-3">
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${summary.activityCount}</div>
<div class="metric-label">Activities</div>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${distanceKm} km</div>
<div class="metric-label">Distance</div>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${durationHours} h</div>
<div class="metric-label">Duration</div>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${elevationM} m</div>
<div class="metric-label">Elevation</div>
</div>
</div>
</div>
`;
document.getElementById(elementId).innerHTML = html;
}
function displayRecentPRs(prs) {
const container = document.getElementById('recent-prs-list');
if (prs.length === 0) {
container.innerHTML = '<p class="text-muted">No personal records yet. Keep training!</p>';
return;
}
const html = prs.map(pr => {
const value = formatPRValue(pr.recordType, pr.value, pr.unit);
const date = new Date(pr.achievedAt).toLocaleDateString();
return `
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
<div>
<h6 class="mb-1">${formatRecordType(pr.recordType)}</h6>
<small class="text-muted">${pr.activityType} • ${date}</small>
</div>
<div class="text-end">
<strong class="text-primary">${value}</strong>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
}
function displayRecentAchievements(achievements) {
const container = document.getElementById('recent-achievements-list');
if (achievements.length === 0) {
container.innerHTML = '<p class="text-muted">No achievements yet. Complete activities to earn badges!</p>';
return;
}
const html = achievements.map(ach => {
const date = new Date(ach.earnedAt).toLocaleDateString();
return `
<div class="d-flex align-items-center mb-3 pb-3 border-bottom">
<div class="me-3" style="font-size: 2.5rem;">${ach.badgeIcon || '🏆'}</div>
<div>
<h6 class="mb-1">${ach.name}</h6>
<small class="text-muted">${ach.description}</small><br>
<small class="text-muted">${date}</small>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
}
function formatRecordType(type) {
return type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
function formatPRValue(type, value, unit) {
if (unit === 'seconds') {
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const seconds = Math.floor(value % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else if (unit === 'meters') {
return `${(value / 1000).toFixed(2)} km`;
} else if (unit === 'mps') {
return `${(value * 3.6).toFixed(2)} km/h`;
}
return value;
}
// Load dashboard on page load
document.addEventListener('DOMContentLoaded', () => {
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/auth/login';
return;
}
loadDashboard();
});
</script>
<style>
.analytics-link-card {
transition: all 0.3s ease;
border: 2px solid transparent;
}
.analytics-link-card:hover {
border-color: var(--primary-color);
box-shadow: 0 8px 16px rgba(255, 0, 255, 0.2);
}
.stat-card {
border: 2px solid var(--primary-color);
}
</style>
</div>
</body>
</html>

View file

@ -0,0 +1,277 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout :: layout(~{::title}, ~{::content})}">
<head>
<title>Personal Records - FitPub</title>
</head>
<body>
<div th:fragment="content">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-trophy-fill text-warning"></i> Personal Records
</h1>
<a href="/analytics" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
<!-- Loading Spinner -->
<div id="loading-spinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading your personal records...</p>
</div>
<!-- Filter Tabs -->
<div id="pr-content" style="display: none;">
<ul class="nav nav-tabs mb-4" id="activityTypeTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="all-tab" data-type="ALL" onclick="filterByType('ALL')">
All
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="run-tab" data-type="RUN" onclick="filterByType('RUN')">
🏃 Run
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="ride-tab" data-type="RIDE" onclick="filterByType('RIDE')">
🚴 Ride
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="hike-tab" data-type="HIKE" onclick="filterByType('HIKE')">
🥾 Hike
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="walk-tab" data-type="WALK" onclick="filterByType('WALK')">
🚶 Walk
</button>
</li>
</ul>
<!-- Personal Records Grid -->
<div id="pr-grid" class="row g-4"></div>
<!-- Empty State -->
<div id="empty-state" style="display: none;" class="empty-state empty-state-activities">
<div class="empty-state-icon">🏆</div>
<h3 class="empty-state-title">No Personal Records Yet</h3>
<p class="empty-state-message">Complete more activities to set personal records!</p>
<div class="empty-state-action">
<a href="/activities/upload" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Activity
</a>
</div>
</div>
</div>
<!-- Error Message -->
<div id="error-message" style="display: none;" class="alert alert-danger"></div>
</div>
<script>
let allRecords = [];
async function loadPersonalRecords(activityType = null) {
try {
let url = '/api/analytics/personal-records';
if (activityType && activityType !== 'ALL') {
url += `?activityType=${activityType}`;
}
const response = await FitPubAuth.authenticatedFetch(url);
if (!response.ok) {
throw new Error('Failed to load personal records');
}
allRecords = await response.json();
displayPersonalRecords(allRecords);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('pr-content').style.display = 'block';
} catch (error) {
console.error('Error loading personal records:', error);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('error-message').textContent = 'Failed to load personal records';
document.getElementById('error-message').style.display = 'block';
}
}
function filterByType(type) {
// Update active tab
document.querySelectorAll('#activityTypeTabs .nav-link').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-type="${type}"]`).classList.add('active');
// Filter records
if (type === 'ALL') {
displayPersonalRecords(allRecords);
} else {
const filtered = allRecords.filter(pr => pr.activityType === type);
displayPersonalRecords(filtered);
}
}
function displayPersonalRecords(records) {
const grid = document.getElementById('pr-grid');
const emptyState = document.getElementById('empty-state');
if (records.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
grid.style.display = 'flex';
emptyState.style.display = 'none';
const html = records.map(pr => {
const value = formatPRValue(pr.recordType, pr.value, pr.unit);
const date = new Date(pr.achievedAt).toLocaleDateString();
const icon = getRecordTypeIcon(pr.recordType);
const improvement = pr.previousValue ? calculateImprovement(pr) : null;
return `
<div class="col-md-6 col-lg-4">
<div class="card h-100 pr-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div style="font-size: 2.5rem;">${icon}</div>
<span class="badge" style="background: var(--primary-color);">${pr.activityType}</span>
</div>
<h5 class="card-title">${formatRecordType(pr.recordType)}</h5>
<h2 class="text-primary mb-2">${value}</h2>
${improvement ? `
<div class="alert alert-success py-1 px-2 mb-2">
<small><i class="bi bi-graph-up-arrow"></i> ${improvement}</small>
</div>
` : ''}
<div class="text-muted">
<small><i class="bi bi-calendar"></i> ${date}</small>
</div>
${pr.activityId ? `
<a href="/activities/detail/${pr.activityId}" class="btn btn-sm btn-outline-primary mt-3 w-100">
View Activity <i class="bi bi-arrow-right"></i>
</a>
` : ''}
</div>
</div>
</div>
`;
}).join('');
grid.innerHTML = html;
}
function getRecordTypeIcon(type) {
const icons = {
'FASTEST_1K': '⚡',
'FASTEST_5K': '🏃‍♂️',
'FASTEST_10K': '💨',
'FASTEST_HALF_MARATHON': '🏃',
'FASTEST_MARATHON': '🏅',
'LONGEST_DISTANCE': '📏',
'LONGEST_DURATION': '⏱️',
'HIGHEST_ELEVATION_GAIN': '⛰️',
'MAX_SPEED': '🚀',
'BEST_AVERAGE_PACE': '⚡'
};
return icons[type] || '🏆';
}
function formatRecordType(type) {
return type.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
function formatPRValue(type, value, unit) {
if (unit === 'seconds' || unit === 'seconds_per_km') {
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const seconds = Math.floor(value % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else if (unit === 'meters') {
return `${(value / 1000).toFixed(2)} km`;
} else if (unit === 'mps') {
return `${(value * 3.6).toFixed(2)} km/h`;
}
return value;
}
function calculateImprovement(pr) {
if (!pr.previousValue) return null;
const current = parseFloat(pr.value);
const previous = parseFloat(pr.previousValue);
// For time/pace records, lower is better
if (pr.unit === 'seconds' || pr.unit === 'seconds_per_km') {
const diff = previous - current;
const percentImprove = (diff / previous * 100).toFixed(1);
const timeDiff = formatTimeDiff(diff);
return `${timeDiff} faster (${percentImprove}% improvement)`;
} else {
// For distance/speed, higher is better
const diff = current - previous;
const percentImprove = (diff / previous * 100).toFixed(1);
if (pr.unit === 'meters') {
return `${(diff / 1000).toFixed(2)} km more (${percentImprove}% improvement)`;
} else if (pr.unit === 'mps') {
return `${(diff * 3.6).toFixed(2)} km/h faster (${percentImprove}% improvement)`;
}
}
return null;
}
function formatTimeDiff(seconds) {
const mins = Math.floor(Math.abs(seconds) / 60);
const secs = Math.floor(Math.abs(seconds) % 60);
if (mins > 0) {
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
return `${secs}s`;
}
// Load on page load
document.addEventListener('DOMContentLoaded', () => {
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/auth/login';
return;
}
loadPersonalRecords();
});
</script>
<style>
.pr-card {
transition: all 0.3s ease;
border: 2px solid var(--light-color);
}
.pr-card:hover {
border-color: var(--primary-color);
box-shadow: 0 8px 16px rgba(255, 0, 255, 0.2);
}
#activityTypeTabs .nav-link {
cursor: pointer;
}
#activityTypeTabs .nav-link.active {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
border-color: var(--primary-color);
}
</style>
</div>
</body>
</html>

View file

@ -0,0 +1,264 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout :: layout(~{::title}, ~{::content})}">
<head>
<title>Activity Summaries - FitPub</title>
</head>
<body>
<div th:fragment="content">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-calendar-range" style="color: var(--accent-lime);"></i> Activity Summaries
</h1>
<a href="/analytics" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
<!-- Period Tabs -->
<ul class="nav nav-tabs mb-4" id="periodTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="weekly-tab" onclick="switchPeriod('weekly')">
📅 Weekly
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="monthly-tab" onclick="switchPeriod('monthly')">
📆 Monthly
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="yearly-tab" onclick="switchPeriod('yearly')">
📊 Yearly
</button>
</li>
</ul>
<!-- Loading Spinner -->
<div id="loading-spinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading summaries...</p>
</div>
<!-- Summaries Content -->
<div id="summaries-content" style="display: none;">
<div id="summaries-list" class="row g-4"></div>
<!-- Empty State -->
<div id="empty-state" style="display: none;" class="empty-state empty-state-activities">
<div class="empty-state-icon">📊</div>
<h3 class="empty-state-title">No Data Available</h3>
<p class="empty-state-message">Complete activities to see your summaries!</p>
</div>
</div>
<!-- Error Message -->
<div id="error-message" style="display: none;" class="alert alert-danger"></div>
</div>
<script>
let currentPeriod = 'weekly';
function switchPeriod(period) {
currentPeriod = period;
// Update active tab
document.querySelectorAll('#periodTabs button').forEach(btn => btn.classList.remove('active'));
document.getElementById(`${period}-tab`).classList.add('active');
loadSummaries(period);
}
async function loadSummaries(period) {
try {
document.getElementById('loading-spinner').style.display = 'block';
document.getElementById('summaries-content').style.display = 'none';
const params = period === 'weekly' ? 12 : period === 'monthly' ? 12 : 5;
const response = await FitPubAuth.authenticatedFetch(`/api/analytics/summaries/${period}?${period === 'weekly' ? 'weeks' : period === 'monthly' ? 'months' : 'years'}=${params}`);
if (!response.ok) {
throw new Error('Failed to load summaries');
}
const summaries = await response.json();
displaySummaries(summaries, period);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('summaries-content').style.display = 'block';
} catch (error) {
console.error('Error loading summaries:', error);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('error-message').textContent = 'Failed to load summaries';
document.getElementById('error-message').style.display = 'block';
}
}
function displaySummaries(summaries, period) {
const list = document.getElementById('summaries-list');
const emptyState = document.getElementById('empty-state');
if (summaries.length === 0) {
list.style.display = 'none';
emptyState.style.display = 'block';
return;
}
list.style.display = 'flex';
emptyState.style.display = 'none';
const html = summaries.map(summary => {
const startDate = new Date(summary.periodStart).toLocaleDateString();
const endDate = new Date(summary.periodEnd).toLocaleDateString();
const distanceKm = (summary.totalDistanceMeters / 1000).toFixed(2);
const durationHours = (summary.totalDurationSeconds / 3600).toFixed(1);
const elevationM = summary.totalElevationGainMeters?.toFixed(0) || 0;
const avgSpeedKmh = summary.avgSpeedMps ? (summary.avgSpeedMps * 3.6).toFixed(2) : 'N/A';
const maxSpeedKmh = summary.maxSpeedMps ? (summary.maxSpeedMps * 3.6).toFixed(2) : 'N/A';
const typeBreakdownHtml = formatTypeBreakdown(summary.activityTypeBreakdown);
return `
<div class="col-lg-6">
<div class="card summary-card h-100">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">${getPeriodLabel(period)}</h5>
<span class="badge bg-primary">${startDate} - ${endDate}</span>
</div>
</div>
<div class="card-body">
<!-- Main Stats -->
<div class="row g-3 mb-3">
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${summary.activityCount}</div>
<div class="metric-label">Activities</div>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${distanceKm} km</div>
<div class="metric-label">Distance</div>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${durationHours} h</div>
<div class="metric-label">Duration</div>
</div>
</div>
<div class="col-6">
<div class="metric-card">
<div class="metric-value">${elevationM} m</div>
<div class="metric-label">Elevation</div>
</div>
</div>
</div>
<!-- Speed Stats -->
<div class="row g-2 mb-3">
<div class="col-6">
<small class="text-muted">Avg Speed:</small>
<strong>${avgSpeedKmh} km/h</strong>
</div>
<div class="col-6">
<small class="text-muted">Max Speed:</small>
<strong>${maxSpeedKmh} km/h</strong>
</div>
</div>
<!-- Activity Type Breakdown -->
${typeBreakdownHtml}
<!-- PRs and Achievements -->
${summary.personalRecordsSet > 0 || summary.achievementsEarned > 0 ? `
<div class="alert alert-success mt-3 mb-0">
${summary.personalRecordsSet > 0 ? `<div><i class="bi bi-trophy"></i> <strong>${summary.personalRecordsSet}</strong> Personal Records</div>` : ''}
${summary.achievementsEarned > 0 ? `<div><i class="bi bi-award"></i> <strong>${summary.achievementsEarned}</strong> Achievements</div>` : ''}
</div>
` : ''}
</div>
</div>
</div>
`;
}).join('');
list.innerHTML = html;
}
function getPeriodLabel(period) {
const labels = {
'weekly': 'Week',
'monthly': 'Month',
'yearly': 'Year'
};
return labels[period] || period;
}
function formatTypeBreakdown(breakdown) {
if (!breakdown || Object.keys(breakdown).length === 0) {
return '';
}
const icons = {
'RUN': '🏃',
'RIDE': '🚴',
'HIKE': '🥾',
'WALK': '🚶',
'SWIM': '🏊'
};
const html = Object.entries(breakdown)
.map(([type, count]) => {
const icon = icons[type] || '💪';
return `<span class="badge bg-secondary me-2">${icon} ${type} (${count})</span>`;
})
.join('');
return `
<div class="mb-3">
<small class="text-muted d-block mb-2">Activity Types:</small>
${html}
</div>
`;
}
// Load on page load
document.addEventListener('DOMContentLoaded', () => {
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/auth/login';
return;
}
loadSummaries('weekly');
});
</script>
<style>
.summary-card {
transition: all 0.3s ease;
border: 2px solid var(--light-color);
}
.summary-card:hover {
border-color: var(--accent-lime);
box-shadow: 0 8px 16px rgba(204, 255, 0, 0.2);
}
#periodTabs .nav-link.active {
background: linear-gradient(135deg, var(--accent-lime) 0%, var(--secondary-color) 100%);
color: var(--dark-color);
border-color: var(--accent-lime);
font-weight: 700;
}
#periodTabs .nav-link {
cursor: pointer;
}
</style>
</div>
</body>
</html>

View file

@ -0,0 +1,291 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout :: layout(~{::title}, ~{::content})}">
<head>
<title>Training Load - FitPub</title>
</head>
<body>
<div th:fragment="content">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>
<i class="bi bi-graph-up" style="color: var(--secondary-color);"></i> Training Load
</h1>
<a href="/analytics" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
<!-- Form Status Card -->
<div class="card mb-4" id="form-status-card">
<div class="card-body text-center">
<h3 class="mb-2">Current Form Status</h3>
<h2 id="form-status-display" class="mb-2">-</h2>
<p id="form-status-description" class="text-muted mb-0"></p>
</div>
</div>
<!-- Period Selector -->
<div class="btn-group mb-4 w-100" role="group">
<button type="button" class="btn btn-outline-primary active" onclick="loadTrainingLoad(30)">
Last 30 Days
</button>
<button type="button" class="btn btn-outline-primary" onclick="loadTrainingLoad(60)">
Last 60 Days
</button>
<button type="button" class="btn btn-outline-primary" onclick="loadTrainingLoad(90)">
Last 90 Days
</button>
</div>
<!-- Loading Spinner -->
<div id="loading-spinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading training load data...</p>
</div>
<!-- Charts -->
<div id="charts-content" style="display: none;">
<!-- Training Stress Score Chart -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-activity"></i> Training Stress Score (TSS)</h5>
<small class="text-muted">Daily training load intensity</small>
</div>
<div class="card-body">
<canvas id="tss-chart"></canvas>
</div>
</div>
<!-- ATL vs CTL Chart -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-graph-up-arrow"></i> Acute vs Chronic Training Load</h5>
<small class="text-muted">7-day fatigue vs 28-day fitness</small>
</div>
<div class="card-body">
<canvas id="atl-ctl-chart"></canvas>
</div>
</div>
<!-- Training Stress Balance Chart -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-bar-chart"></i> Training Stress Balance (TSB)</h5>
<small class="text-muted">Fitness - Fatigue (positive = fresh, negative = fatigued)</small>
</div>
<div class="card-body">
<canvas id="tsb-chart"></canvas>
</div>
</div>
</div>
<!-- Error Message -->
<div id="error-message" style="display: none;" class="alert alert-danger"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
let tssChart, atlCtlChart, tsbChart;
async function loadFormStatus() {
try {
const response = await FitPubAuth.authenticatedFetch('/api/analytics/form-status');
if (response.ok) {
const data = await response.json();
displayFormStatus(data.formStatus, data.description);
}
} catch (error) {
console.error('Error loading form status:', error);
}
}
function displayFormStatus(status, description) {
const statusDisplay = document.getElementById('form-status-display');
const descriptionEl = document.getElementById('form-status-description');
const card = document.getElementById('form-status-card');
const statusConfig = {
'FRESH': { text: '😊 Fresh', color: '#10b981', bgColor: '#d1fae5' },
'OPTIMAL': { text: '💪 Optimal', color: '#00ffff', bgColor: '#e0ffff' },
'FATIGUED': { text: '😴 Fatigued', color: '#ef4444', bgColor: '#fee2e2' },
'UNKNOWN': { text: '❓ Unknown', color: '#6b7280', bgColor: '#f3f4f6' }
};
const config = statusConfig[status] || statusConfig['UNKNOWN'];
statusDisplay.textContent = config.text;
statusDisplay.style.color = config.color;
descriptionEl.textContent = description;
card.style.backgroundColor = config.bgColor;
}
async function loadTrainingLoad(days = 30) {
try {
// Update active button
document.querySelectorAll('.btn-group button').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('loading-spinner').style.display = 'block';
document.getElementById('charts-content').style.display = 'none';
const response = await FitPubAuth.authenticatedFetch(`/api/analytics/training-load?days=${days}`);
if (!response.ok) {
throw new Error('Failed to load training load data');
}
const data = await response.json();
displayCharts(data);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('charts-content').style.display = 'block';
} catch (error) {
console.error('Error loading training load:', error);
document.getElementById('loading-spinner').style.display = 'none';
document.getElementById('error-message').textContent = 'Failed to load training load data';
document.getElementById('error-message').style.display = 'block';
}
}
function displayCharts(data) {
// Reverse data to show chronologically
data = data.reverse();
const dates = data.map(d => new Date(d.date).toLocaleDateString());
const tss = data.map(d => d.trainingStressScore || 0);
const atl = data.map(d => d.acuteTrainingLoad || 0);
const ctl = data.map(d => d.chronicTrainingLoad || 0);
const tsb = data.map(d => d.trainingStressBalance || 0);
// Destroy existing charts
if (tssChart) tssChart.destroy();
if (atlCtlChart) atlCtlChart.destroy();
if (tsbChart) tsbChart.destroy();
// TSS Chart
const tssCtx = document.getElementById('tss-chart').getContext('2d');
tssChart = new Chart(tssCtx, {
type: 'bar',
data: {
labels: dates,
datasets: [{
label: 'Training Stress Score',
data: tss,
backgroundColor: 'rgba(255, 0, 255, 0.5)',
borderColor: 'rgb(255, 0, 255)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2.5,
plugins: {
legend: { display: true }
},
scales: {
y: { beginAtZero: true }
}
}
});
// ATL vs CTL Chart
const atlCtlCtx = document.getElementById('atl-ctl-chart').getContext('2d');
atlCtlChart = new Chart(atlCtlCtx, {
type: 'line',
data: {
labels: dates,
datasets: [
{
label: 'Acute Load (Fatigue)',
data: atl,
borderColor: 'rgb(255, 20, 147)',
backgroundColor: 'rgba(255, 20, 147, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Chronic Load (Fitness)',
data: ctl,
borderColor: 'rgb(0, 255, 255)',
backgroundColor: 'rgba(0, 255, 255, 0.1)',
tension: 0.4,
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2.5,
plugins: {
legend: { display: true }
},
scales: {
y: { beginAtZero: true }
}
}
});
// TSB Chart
const tsbCtx = document.getElementById('tsb-chart').getContext('2d');
tsbChart = new Chart(tsbCtx, {
type: 'line',
data: {
labels: dates,
datasets: [{
label: 'Training Stress Balance',
data: tsb,
borderColor: 'rgb(204, 255, 0)',
backgroundColor: function(context) {
const value = context.parsed.y;
return value >= 0 ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)';
},
segment: {
borderColor: function(context) {
const value = context.p1.parsed.y;
return value >= 0 ? 'rgb(16, 185, 129)' : 'rgb(239, 68, 68)';
}
},
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2.5,
plugins: {
legend: { display: true }
},
scales: {
y: {
grid: {
color: function(context) {
if (context.tick.value === 0) {
return 'rgba(0, 0, 0, 0.3)';
}
return 'rgba(0, 0, 0, 0.1)';
}
}
}
}
}
});
}
// Load on page load
document.addEventListener('DOMContentLoaded', () => {
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/auth/login';
return;
}
loadFormStatus();
loadTrainingLoad(30);
});
</script>
</div>
</body>
</html>

View file

@ -70,6 +70,11 @@
<i class="bi bi-cloud-upload"></i> Upload
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/analytics}" id="analyticsLink" style="display: none;">
<i class="bi bi-graph-up"></i> Analytics
</a>
</li>
</ul>
<!-- Right side navigation -->