Nice things
This commit is contained in:
parent
6dccf87aec
commit
362680f774
27 changed files with 3833 additions and 4 deletions
|
|
@ -794,7 +794,6 @@ For ActivityPub federated posts and thumbnails:
|
|||
### Phase 3: Advanced Analytics
|
||||
- [ ] Personal records tracking
|
||||
- [ ] Training load and recovery metrics
|
||||
- [ ] Segment comparison (Strava-like)
|
||||
- [ ] Achievement/badge system
|
||||
- [ ] Weekly/monthly summaries
|
||||
- [ ] Route recommendations
|
||||
|
|
|
|||
BIN
img.png
BIN
img.png
Binary file not shown.
|
Before Width: | Height: | Size: 852 KiB |
|
|
@ -0,0 +1,270 @@
|
|||
package org.operaton.fitpub.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.*;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.*;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for analytics and statistics.
|
||||
* Provides personal records, achievements, training load, and summaries.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/analytics")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AnalyticsController {
|
||||
|
||||
private final PersonalRecordService personalRecordService;
|
||||
private final AchievementService achievementService;
|
||||
private final TrainingLoadService trainingLoadService;
|
||||
private final ActivitySummaryService activitySummaryService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Get user ID from authenticated user.
|
||||
*/
|
||||
private UUID getUserId(UserDetails userDetails) {
|
||||
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + userDetails.getUsername()));
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics dashboard data (overview).
|
||||
*/
|
||||
@GetMapping("/dashboard")
|
||||
public ResponseEntity<Map<String, Object>> getDashboard(
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
|
||||
Map<String, Object> dashboard = new HashMap<>();
|
||||
|
||||
// Personal records count
|
||||
long prCount = personalRecordService.getPersonalRecordCount(userId);
|
||||
dashboard.put("personalRecordsCount", prCount);
|
||||
|
||||
// Achievements count
|
||||
long achievementCount = achievementService.getAchievementCount(userId);
|
||||
dashboard.put("achievementsCount", achievementCount);
|
||||
|
||||
// Recent personal records (last 5)
|
||||
List<PersonalRecord> recentPRs = personalRecordService.getPersonalRecords(userId)
|
||||
.stream()
|
||||
.limit(5)
|
||||
.toList();
|
||||
dashboard.put("recentPersonalRecords", recentPRs);
|
||||
|
||||
// Recent achievements (last 5)
|
||||
List<Achievement> recentAchievements = achievementService.getUserAchievements(userId)
|
||||
.stream()
|
||||
.limit(5)
|
||||
.toList();
|
||||
dashboard.put("recentAchievements", recentAchievements);
|
||||
|
||||
// Current form status
|
||||
TrainingLoad.FormStatus formStatus = trainingLoadService.getCurrentFormStatus(userId);
|
||||
dashboard.put("formStatus", formStatus);
|
||||
|
||||
// Current week summary
|
||||
ActivitySummary currentWeek = activitySummaryService.getCurrentWeekSummary(userId);
|
||||
dashboard.put("currentWeekSummary", currentWeek);
|
||||
|
||||
// Current month summary
|
||||
ActivitySummary currentMonth = activitySummaryService.getCurrentMonthSummary(userId);
|
||||
dashboard.put("currentMonthSummary", currentMonth);
|
||||
|
||||
return ResponseEntity.ok(dashboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all personal records for the authenticated user.
|
||||
*/
|
||||
@GetMapping("/personal-records")
|
||||
public ResponseEntity<List<PersonalRecord>> getPersonalRecords(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(required = false) String activityType) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
|
||||
List<PersonalRecord> records;
|
||||
if (activityType != null) {
|
||||
PersonalRecord.ActivityType type = PersonalRecord.ActivityType.valueOf(activityType.toUpperCase());
|
||||
records = personalRecordService.getPersonalRecordsByType(userId, type);
|
||||
} else {
|
||||
records = personalRecordService.getPersonalRecords(userId);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all achievements for the authenticated user.
|
||||
*/
|
||||
@GetMapping("/achievements")
|
||||
public ResponseEntity<List<Achievement>> getAchievements(
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
List<Achievement> achievements = achievementService.getUserAchievements(userId);
|
||||
|
||||
return ResponseEntity.ok(achievements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get training load for a date range.
|
||||
*/
|
||||
@GetMapping("/training-load")
|
||||
public ResponseEntity<List<TrainingLoad>> getTrainingLoad(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(required = false) Integer days) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
|
||||
List<TrainingLoad> trainingLoad;
|
||||
if (days != null && days > 0) {
|
||||
trainingLoad = trainingLoadService.getRecentTrainingLoad(userId, days);
|
||||
} else {
|
||||
// Default to last 30 days
|
||||
trainingLoad = trainingLoadService.getRecentTrainingLoad(userId, 30);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(trainingLoad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get training load for specific date range.
|
||||
*/
|
||||
@GetMapping("/training-load/range")
|
||||
public ResponseEntity<List<TrainingLoad>> getTrainingLoadRange(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String startDate,
|
||||
@RequestParam String endDate) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
LocalDate start = LocalDate.parse(startDate);
|
||||
LocalDate end = LocalDate.parse(endDate);
|
||||
|
||||
List<TrainingLoad> trainingLoad = trainingLoadService.getTrainingLoad(userId, start, end);
|
||||
|
||||
return ResponseEntity.ok(trainingLoad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current form status.
|
||||
*/
|
||||
@GetMapping("/form-status")
|
||||
public ResponseEntity<Map<String, Object>> getFormStatus(
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
TrainingLoad.FormStatus formStatus = trainingLoadService.getCurrentFormStatus(userId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("formStatus", formStatus);
|
||||
response.put("description", getFormStatusDescription(formStatus));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekly summaries.
|
||||
*/
|
||||
@GetMapping("/summaries/weekly")
|
||||
public ResponseEntity<List<ActivitySummary>> getWeeklySummaries(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "12") int weeks) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
List<ActivitySummary> summaries = activitySummaryService.getWeeklySummaries(userId, weeks);
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly summaries.
|
||||
*/
|
||||
@GetMapping("/summaries/monthly")
|
||||
public ResponseEntity<List<ActivitySummary>> getMonthlySummaries(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "12") int months) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
List<ActivitySummary> summaries = activitySummaryService.getMonthlySummaries(userId, months);
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get yearly summaries.
|
||||
*/
|
||||
@GetMapping("/summaries/yearly")
|
||||
public ResponseEntity<List<ActivitySummary>> getYearlySummaries(
|
||||
@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "5") int years) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
List<ActivitySummary> summaries = activitySummaryService.getYearlySummaries(userId, years);
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current week summary.
|
||||
*/
|
||||
@GetMapping("/summaries/current-week")
|
||||
public ResponseEntity<ActivitySummary> getCurrentWeekSummary(
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
ActivitySummary summary = activitySummaryService.getCurrentWeekSummary(userId);
|
||||
|
||||
if (summary == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current month summary.
|
||||
*/
|
||||
@GetMapping("/summaries/current-month")
|
||||
public ResponseEntity<ActivitySummary> getCurrentMonthSummary(
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
|
||||
UUID userId = getUserId(userDetails);
|
||||
ActivitySummary summary = activitySummaryService.getCurrentMonthSummary(userId);
|
||||
|
||||
if (summary == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form status description.
|
||||
*/
|
||||
private String getFormStatusDescription(TrainingLoad.FormStatus status) {
|
||||
return switch (status) {
|
||||
case FRESH -> "You're well rested and ready for hard training!";
|
||||
case OPTIMAL -> "Good balance between fitness and fatigue.";
|
||||
case FATIGUED -> "High fatigue detected. Consider taking a rest day.";
|
||||
case UNKNOWN -> "Not enough data to calculate form status.";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package org.operaton.fitpub.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
/**
|
||||
* View controller for analytics pages.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/analytics")
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsViewController {
|
||||
|
||||
@GetMapping("")
|
||||
public String analytics() {
|
||||
return "analytics/dashboard";
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
public String dashboard() {
|
||||
return "analytics/dashboard";
|
||||
}
|
||||
|
||||
@GetMapping("/personal-records")
|
||||
public String personalRecords() {
|
||||
return "analytics/personal-records";
|
||||
}
|
||||
|
||||
@GetMapping("/achievements")
|
||||
public String achievements() {
|
||||
return "analytics/achievements";
|
||||
}
|
||||
|
||||
@GetMapping("/training-load")
|
||||
public String trainingLoad() {
|
||||
return "analytics/training-load";
|
||||
}
|
||||
|
||||
@GetMapping("/summaries")
|
||||
public String summaries() {
|
||||
return "analytics/summaries";
|
||||
}
|
||||
}
|
||||
117
src/main/java/org/operaton/fitpub/model/entity/Achievement.java
Normal file
117
src/main/java/org/operaton/fitpub/model/entity/Achievement.java
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity representing an achievement/badge earned by a user.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "achievements",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "achievement_type"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Achievement {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "achievement_type", nullable = false, length = 100)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private AchievementType achievementType;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "badge_icon", length = 50)
|
||||
private String badgeIcon; // Emoji or icon class
|
||||
|
||||
@Column(name = "badge_color", length = 20)
|
||||
private String badgeColor;
|
||||
|
||||
@Column(name = "earned_at", nullable = false)
|
||||
private LocalDateTime earnedAt;
|
||||
|
||||
@Column(name = "activity_id")
|
||||
private UUID activityId;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "jsonb")
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* Types of achievements that can be earned.
|
||||
*/
|
||||
public enum AchievementType {
|
||||
// First time achievements
|
||||
FIRST_ACTIVITY,
|
||||
FIRST_RUN,
|
||||
FIRST_RIDE,
|
||||
FIRST_HIKE,
|
||||
|
||||
// Distance milestones
|
||||
DISTANCE_10K,
|
||||
DISTANCE_50K,
|
||||
DISTANCE_100K,
|
||||
DISTANCE_500K,
|
||||
DISTANCE_1000K,
|
||||
|
||||
// Activity count milestones
|
||||
ACTIVITIES_10,
|
||||
ACTIVITIES_50,
|
||||
ACTIVITIES_100,
|
||||
ACTIVITIES_500,
|
||||
ACTIVITIES_1000,
|
||||
|
||||
// Streak achievements
|
||||
STREAK_7_DAYS,
|
||||
STREAK_30_DAYS,
|
||||
STREAK_100_DAYS,
|
||||
STREAK_365_DAYS,
|
||||
|
||||
// Time-based achievements
|
||||
EARLY_BIRD, // 5+ activities before 6am
|
||||
NIGHT_OWL, // 5+ activities after 10pm
|
||||
WEEKEND_WARRIOR, // 10+ weekend activities
|
||||
|
||||
// Elevation achievements
|
||||
MOUNTAINEER_1000M, // 1000m elevation gain in single activity
|
||||
MOUNTAINEER_5000M, // 5000m total elevation gain
|
||||
MOUNTAINEER_10000M, // 10000m total elevation gain
|
||||
|
||||
// Consistency achievements
|
||||
CONSISTENT_WEEK, // 7 days in a row
|
||||
CONSISTENT_MONTH, // 30 days in a row
|
||||
|
||||
// Exploration achievements
|
||||
EXPLORER, // Activities in 5+ different locations
|
||||
GLOBE_TROTTER, // Activities in 10+ different locations
|
||||
|
||||
// Speed achievements
|
||||
SPEED_DEMON, // Max speed > 50 km/h
|
||||
|
||||
// Variety achievements
|
||||
VARIETY_SEEKER // 3+ different activity types
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity representing pre-calculated activity summaries for a time period.
|
||||
* Used for performance optimization of weekly/monthly/yearly statistics.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "activity_summaries",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "period_type", "period_start"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ActivitySummary {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "period_type", nullable = false, length = 20)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private PeriodType periodType;
|
||||
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDate periodStart;
|
||||
|
||||
@Column(name = "period_end", nullable = false)
|
||||
private LocalDate periodEnd;
|
||||
|
||||
@Column(name = "activity_count")
|
||||
@Builder.Default
|
||||
private Integer activityCount = 0;
|
||||
|
||||
@Column(name = "total_duration_seconds")
|
||||
@Builder.Default
|
||||
private Long totalDurationSeconds = 0L;
|
||||
|
||||
@Column(name = "total_distance_meters", precision = 10, scale = 2)
|
||||
@Builder.Default
|
||||
private BigDecimal totalDistanceMeters = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "total_elevation_gain_meters", precision = 10, scale = 2)
|
||||
@Builder.Default
|
||||
private BigDecimal totalElevationGainMeters = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "avg_speed_mps", precision = 6, scale = 2)
|
||||
private BigDecimal avgSpeedMps;
|
||||
|
||||
@Column(name = "max_speed_mps", precision = 6, scale = 2)
|
||||
private BigDecimal maxSpeedMps;
|
||||
|
||||
/**
|
||||
* Breakdown of activities by type.
|
||||
* Example: {"Run": 5, "Ride": 3, "Hike": 2}
|
||||
*/
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "activity_type_breakdown", columnDefinition = "jsonb")
|
||||
private Map<String, Integer> activityTypeBreakdown;
|
||||
|
||||
@Column(name = "personal_records_set")
|
||||
@Builder.Default
|
||||
private Integer personalRecordsSet = 0;
|
||||
|
||||
@Column(name = "achievements_earned")
|
||||
@Builder.Default
|
||||
private Integer achievementsEarned = 0;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Period type for the summary.
|
||||
*/
|
||||
public enum PeriodType {
|
||||
WEEK,
|
||||
MONTH,
|
||||
YEAR
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity representing a personal record (PR) for a user.
|
||||
* Tracks best performances across different metrics and activity types.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "personal_records",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "activity_type", "record_type"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PersonalRecord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "activity_type", nullable = false, length = 50)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private ActivityType activityType;
|
||||
|
||||
@Column(name = "record_type", nullable = false, length = 50)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private RecordType recordType;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal value;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String unit;
|
||||
|
||||
@Column(name = "activity_id")
|
||||
private UUID activityId;
|
||||
|
||||
@Column(name = "achieved_at", nullable = false)
|
||||
private LocalDateTime achievedAt;
|
||||
|
||||
@Column(name = "previous_value", precision = 10, scale = 2)
|
||||
private BigDecimal previousValue;
|
||||
|
||||
@Column(name = "previous_achieved_at")
|
||||
private LocalDateTime previousAchievedAt;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Type of personal record being tracked.
|
||||
*/
|
||||
public enum RecordType {
|
||||
FASTEST_1K, // Fastest 1 kilometer time
|
||||
FASTEST_5K, // Fastest 5 kilometer time
|
||||
FASTEST_10K, // Fastest 10 kilometer time
|
||||
FASTEST_HALF_MARATHON, // Fastest half marathon (21.1 km)
|
||||
FASTEST_MARATHON, // Fastest marathon (42.2 km)
|
||||
LONGEST_DISTANCE, // Longest distance in single activity
|
||||
LONGEST_DURATION, // Longest duration in single activity
|
||||
HIGHEST_ELEVATION_GAIN, // Highest elevation gain in single activity
|
||||
MAX_SPEED, // Maximum speed achieved
|
||||
BEST_AVERAGE_PACE // Best average pace for activity type
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity type for the personal record.
|
||||
*/
|
||||
public enum ActivityType {
|
||||
RUN,
|
||||
RIDE,
|
||||
HIKE,
|
||||
WALK,
|
||||
SWIM,
|
||||
OTHER
|
||||
}
|
||||
}
|
||||
114
src/main/java/org/operaton/fitpub/model/entity/TrainingLoad.java
Normal file
114
src/main/java/org/operaton/fitpub/model/entity/TrainingLoad.java
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity representing daily training load metrics for a user.
|
||||
* Used to calculate training stress, acute/chronic load, and recovery needs.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "training_load",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "date"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class TrainingLoad {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate date;
|
||||
|
||||
@Column(name = "activity_count")
|
||||
@Builder.Default
|
||||
private Integer activityCount = 0;
|
||||
|
||||
@Column(name = "total_duration_seconds")
|
||||
@Builder.Default
|
||||
private Long totalDurationSeconds = 0L;
|
||||
|
||||
@Column(name = "total_distance_meters", precision = 10, scale = 2)
|
||||
@Builder.Default
|
||||
private BigDecimal totalDistanceMeters = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "total_elevation_gain_meters", precision = 10, scale = 2)
|
||||
@Builder.Default
|
||||
private BigDecimal totalElevationGainMeters = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* Training Stress Score (TSS) - normalized training load for the day.
|
||||
* Calculated based on duration, intensity, and elevation.
|
||||
*/
|
||||
@Column(name = "training_stress_score", precision = 6, scale = 2)
|
||||
private BigDecimal trainingStressScore;
|
||||
|
||||
/**
|
||||
* Acute Training Load (ATL) - 7-day rolling average of TSS.
|
||||
* Represents recent training fatigue.
|
||||
*/
|
||||
@Column(name = "acute_training_load", precision = 6, scale = 2)
|
||||
private BigDecimal acuteTrainingLoad;
|
||||
|
||||
/**
|
||||
* Chronic Training Load (CTL) - 28-day rolling average of TSS.
|
||||
* Represents fitness level.
|
||||
*/
|
||||
@Column(name = "chronic_training_load", precision = 6, scale = 2)
|
||||
private BigDecimal chronicTrainingLoad;
|
||||
|
||||
/**
|
||||
* Training Stress Balance (TSB) = CTL - ATL.
|
||||
* Positive values indicate freshness/recovery.
|
||||
* Negative values indicate fatigue.
|
||||
*/
|
||||
@Column(name = "training_stress_balance", precision = 6, scale = 2)
|
||||
private BigDecimal trainingStressBalance;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Get form status based on TSB.
|
||||
*/
|
||||
public FormStatus getFormStatus() {
|
||||
if (trainingStressBalance == null) {
|
||||
return FormStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
BigDecimal tsb = trainingStressBalance;
|
||||
if (tsb.compareTo(BigDecimal.valueOf(5)) > 0) {
|
||||
return FormStatus.FRESH;
|
||||
} else if (tsb.compareTo(BigDecimal.valueOf(-10)) < 0) {
|
||||
return FormStatus.FATIGUED;
|
||||
} else {
|
||||
return FormStatus.OPTIMAL;
|
||||
}
|
||||
}
|
||||
|
||||
public enum FormStatus {
|
||||
FRESH, // Well rested, ready for hard training
|
||||
OPTIMAL, // Good balance between fitness and fatigue
|
||||
FATIGUED, // High fatigue, need recovery
|
||||
UNKNOWN // Not enough data
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.Achievement;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface AchievementRepository extends JpaRepository<Achievement, UUID> {
|
||||
|
||||
/**
|
||||
* Find all achievements for a user, ordered by most recent first.
|
||||
*/
|
||||
List<Achievement> findByUserIdOrderByEarnedAtDesc(UUID userId);
|
||||
|
||||
/**
|
||||
* Find a specific achievement by user and type.
|
||||
*/
|
||||
Optional<Achievement> findByUserIdAndAchievementType(
|
||||
UUID userId,
|
||||
Achievement.AchievementType achievementType
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a user has earned a specific achievement.
|
||||
*/
|
||||
boolean existsByUserIdAndAchievementType(
|
||||
UUID userId,
|
||||
Achievement.AchievementType achievementType
|
||||
);
|
||||
|
||||
/**
|
||||
* Get count of achievements earned by a user.
|
||||
*/
|
||||
long countByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Get count of achievements earned by a user in a date range.
|
||||
*/
|
||||
@Query("SELECT COUNT(a) FROM Achievement a " +
|
||||
"WHERE a.userId = :userId " +
|
||||
"AND a.earnedAt >= :startDate " +
|
||||
"AND a.earnedAt < :endDate")
|
||||
long countByUserIdAndDateRange(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate
|
||||
);
|
||||
|
||||
/**
|
||||
* Find recent achievements for a user.
|
||||
*/
|
||||
@Query("SELECT a FROM Achievement a " +
|
||||
"WHERE a.userId = :userId " +
|
||||
"ORDER BY a.earnedAt DESC " +
|
||||
"LIMIT :limit")
|
||||
List<Achievement> findRecentByUserId(@Param("userId") UUID userId, @Param("limit") int limit);
|
||||
}
|
||||
|
|
@ -139,4 +139,49 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
|||
Activity.Visibility visibility,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* Count activities by user and activity type.
|
||||
*/
|
||||
long countByUserIdAndActivityType(UUID userId, Activity.ActivityType activityType);
|
||||
|
||||
/**
|
||||
* Sum total distance for a user.
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(a.totalDistance), 0) FROM Activity a WHERE a.userId = :userId")
|
||||
java.math.BigDecimal sumDistanceByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Sum total elevation gain for a user.
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(a.elevationGain), 0) FROM Activity a WHERE a.userId = :userId")
|
||||
java.math.BigDecimal sumElevationGainByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Count activities by user and start time before a specific time.
|
||||
*/
|
||||
@Query("SELECT COUNT(a) FROM Activity a WHERE a.userId = :userId " +
|
||||
"AND FUNCTION('TIME', a.startedAt) < :time")
|
||||
long countByUserIdAndStartTimeBefore(@Param("userId") UUID userId, @Param("time") java.time.LocalTime time);
|
||||
|
||||
/**
|
||||
* Count activities by user and start time after a specific time.
|
||||
*/
|
||||
@Query("SELECT COUNT(a) FROM Activity a WHERE a.userId = :userId " +
|
||||
"AND FUNCTION('TIME', a.startedAt) > :time")
|
||||
long countByUserIdAndStartTimeAfter(@Param("userId") UUID userId, @Param("time") java.time.LocalTime time);
|
||||
|
||||
/**
|
||||
* Count distinct activity types for a user.
|
||||
*/
|
||||
@Query("SELECT COUNT(DISTINCT a.activityType) FROM Activity a WHERE a.userId = :userId")
|
||||
long countDistinctActivityTypesByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Check if user has activity on a specific date.
|
||||
*/
|
||||
@Query("SELECT CASE WHEN COUNT(a) > 0 THEN true ELSE false END FROM Activity a " +
|
||||
"WHERE a.userId = :userId " +
|
||||
"AND FUNCTION('DATE', a.startedAt) = :date")
|
||||
boolean existsByUserIdAndDate(@Param("userId") UUID userId, @Param("date") java.time.LocalDate date);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.ActivitySummary;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface ActivitySummaryRepository extends JpaRepository<ActivitySummary, UUID> {
|
||||
|
||||
/**
|
||||
* Find a specific summary by user, period type, and start date.
|
||||
*/
|
||||
Optional<ActivitySummary> findByUserIdAndPeriodTypeAndPeriodStart(
|
||||
UUID userId,
|
||||
ActivitySummary.PeriodType periodType,
|
||||
LocalDate periodStart
|
||||
);
|
||||
|
||||
/**
|
||||
* Find summaries for a user by period type, ordered by most recent first.
|
||||
*/
|
||||
List<ActivitySummary> findByUserIdAndPeriodTypeOrderByPeriodStartDesc(
|
||||
UUID userId,
|
||||
ActivitySummary.PeriodType periodType
|
||||
);
|
||||
|
||||
/**
|
||||
* Find recent summaries for a user of a specific period type.
|
||||
*/
|
||||
@Query("SELECT s FROM ActivitySummary s " +
|
||||
"WHERE s.userId = :userId " +
|
||||
"AND s.periodType = :periodType " +
|
||||
"ORDER BY s.periodStart DESC " +
|
||||
"LIMIT :limit")
|
||||
List<ActivitySummary> findRecentByUserIdAndPeriodType(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("periodType") ActivitySummary.PeriodType periodType,
|
||||
@Param("limit") int limit
|
||||
);
|
||||
|
||||
/**
|
||||
* Find summaries within a date range.
|
||||
*/
|
||||
@Query("SELECT s FROM ActivitySummary s " +
|
||||
"WHERE s.userId = :userId " +
|
||||
"AND s.periodType = :periodType " +
|
||||
"AND s.periodStart >= :startDate " +
|
||||
"AND s.periodEnd <= :endDate " +
|
||||
"ORDER BY s.periodStart DESC")
|
||||
List<ActivitySummary> findByUserIdAndPeriodTypeAndDateRange(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("periodType") ActivitySummary.PeriodType periodType,
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.PersonalRecord;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface PersonalRecordRepository extends JpaRepository<PersonalRecord, UUID> {
|
||||
|
||||
/**
|
||||
* Find all personal records for a user.
|
||||
*/
|
||||
List<PersonalRecord> findByUserIdOrderByAchievedAtDesc(UUID userId);
|
||||
|
||||
/**
|
||||
* Find personal records for a user filtered by activity type.
|
||||
*/
|
||||
List<PersonalRecord> findByUserIdAndActivityTypeOrderByAchievedAtDesc(
|
||||
UUID userId,
|
||||
PersonalRecord.ActivityType activityType
|
||||
);
|
||||
|
||||
/**
|
||||
* Find a specific personal record by user, activity type, and record type.
|
||||
*/
|
||||
Optional<PersonalRecord> findByUserIdAndActivityTypeAndRecordType(
|
||||
UUID userId,
|
||||
PersonalRecord.ActivityType activityType,
|
||||
PersonalRecord.RecordType recordType
|
||||
);
|
||||
|
||||
/**
|
||||
* Get count of personal records set by a user.
|
||||
*/
|
||||
@Query("SELECT COUNT(pr) FROM PersonalRecord pr WHERE pr.userId = :userId")
|
||||
long countByUserId(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Get count of personal records set by a user in a date range.
|
||||
*/
|
||||
@Query("SELECT COUNT(pr) FROM PersonalRecord pr " +
|
||||
"WHERE pr.userId = :userId " +
|
||||
"AND pr.achievedAt >= :startDate " +
|
||||
"AND pr.achievedAt < :endDate")
|
||||
long countByUserIdAndDateRange(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("startDate") java.time.LocalDateTime startDate,
|
||||
@Param("endDate") java.time.LocalDateTime endDate
|
||||
);
|
||||
|
||||
/**
|
||||
* Find recent personal records for a user.
|
||||
*/
|
||||
@Query("SELECT pr FROM PersonalRecord pr " +
|
||||
"WHERE pr.userId = :userId " +
|
||||
"ORDER BY pr.achievedAt DESC " +
|
||||
"LIMIT :limit")
|
||||
List<PersonalRecord> findRecentByUserId(@Param("userId") UUID userId, @Param("limit") int limit);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.TrainingLoad;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface TrainingLoadRepository extends JpaRepository<TrainingLoad, UUID> {
|
||||
|
||||
/**
|
||||
* Find training load for a specific user and date.
|
||||
*/
|
||||
Optional<TrainingLoad> findByUserIdAndDate(UUID userId, LocalDate date);
|
||||
|
||||
/**
|
||||
* Find training load for a user within a date range.
|
||||
*/
|
||||
List<TrainingLoad> findByUserIdAndDateBetweenOrderByDateDesc(
|
||||
UUID userId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate
|
||||
);
|
||||
|
||||
/**
|
||||
* Find recent training load entries for a user.
|
||||
*/
|
||||
@Query("SELECT tl FROM TrainingLoad tl " +
|
||||
"WHERE tl.userId = :userId " +
|
||||
"ORDER BY tl.date DESC " +
|
||||
"LIMIT :limit")
|
||||
List<TrainingLoad> findRecentByUserId(@Param("userId") UUID userId, @Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* Find training load for the last N days.
|
||||
*/
|
||||
@Query("SELECT tl FROM TrainingLoad tl " +
|
||||
"WHERE tl.userId = :userId " +
|
||||
"AND tl.date >= :startDate " +
|
||||
"ORDER BY tl.date DESC")
|
||||
List<TrainingLoad> findByUserIdSinceDate(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("startDate") LocalDate startDate
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the latest training load entry for a user.
|
||||
*/
|
||||
Optional<TrainingLoad> findFirstByUserIdOrderByDateDesc(UUID userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,472 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.Achievement;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.repository.AchievementRepository;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Service for managing achievements and badges.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AchievementService {
|
||||
|
||||
private final AchievementRepository achievementRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
|
||||
/**
|
||||
* Check and award achievements for an activity.
|
||||
* Called after an activity is saved.
|
||||
*
|
||||
* @param activity the activity to check for achievements
|
||||
* @return list of newly earned achievements
|
||||
*/
|
||||
@Transactional
|
||||
public List<Achievement> checkAndAwardAchievements(Activity activity) {
|
||||
List<Achievement> newAchievements = new ArrayList<>();
|
||||
|
||||
if (activity.getUserId() == null) {
|
||||
return newAchievements;
|
||||
}
|
||||
|
||||
UUID userId = activity.getUserId();
|
||||
|
||||
// Check first activity achievements
|
||||
newAchievements.addAll(checkFirstActivityAchievements(userId, activity));
|
||||
|
||||
// Check distance milestones
|
||||
newAchievements.addAll(checkDistanceMilestones(userId));
|
||||
|
||||
// Check activity count milestones
|
||||
newAchievements.addAll(checkActivityCountMilestones(userId));
|
||||
|
||||
// Check streak achievements
|
||||
newAchievements.addAll(checkStreakAchievements(userId));
|
||||
|
||||
// Check time-based achievements
|
||||
newAchievements.addAll(checkTimeBasedAchievements(userId, activity));
|
||||
|
||||
// Check elevation achievements
|
||||
newAchievements.addAll(checkElevationAchievements(userId, activity));
|
||||
|
||||
// Check variety achievements
|
||||
newAchievements.addAll(checkVarietyAchievements(userId));
|
||||
|
||||
// Check speed achievements
|
||||
newAchievements.addAll(checkSpeedAchievements(userId, activity));
|
||||
|
||||
return newAchievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check first activity achievements.
|
||||
*/
|
||||
private List<Achievement> checkFirstActivityAchievements(UUID userId, Activity activity) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
// First activity overall
|
||||
long totalActivities = activityRepository.countByUserId(userId);
|
||||
if (totalActivities == 1) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.FIRST_ACTIVITY,
|
||||
"First Steps",
|
||||
"Completed your first activity!",
|
||||
"🎉",
|
||||
"#ff00ff",
|
||||
activity.getId(),
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
// First activity by type
|
||||
String activityType = activity.getActivityType().name();
|
||||
long typeCount = activityRepository.countByUserIdAndActivityType(userId, activity.getActivityType());
|
||||
|
||||
if (typeCount == 1) {
|
||||
Achievement.AchievementType achievementType = switch (activityType) {
|
||||
case "RUN" -> Achievement.AchievementType.FIRST_RUN;
|
||||
case "RIDE" -> Achievement.AchievementType.FIRST_RIDE;
|
||||
case "HIKE" -> Achievement.AchievementType.FIRST_HIKE;
|
||||
default -> null;
|
||||
};
|
||||
|
||||
if (achievementType != null) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
achievementType,
|
||||
"First " + activityType.toLowerCase(),
|
||||
"Completed your first " + activityType.toLowerCase() + "!",
|
||||
getActivityEmoji(activityType),
|
||||
"#00ffff",
|
||||
activity.getId(),
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check distance milestone achievements.
|
||||
*/
|
||||
private List<Achievement> checkDistanceMilestones(UUID userId) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
// Calculate total distance
|
||||
BigDecimal totalDistance = activityRepository.sumDistanceByUserId(userId);
|
||||
if (totalDistance == null) {
|
||||
return achievements;
|
||||
}
|
||||
|
||||
double totalKm = totalDistance.doubleValue() / 1000.0;
|
||||
|
||||
// Check milestones
|
||||
Map<Double, Achievement.AchievementType> milestones = Map.of(
|
||||
10.0, Achievement.AchievementType.DISTANCE_10K,
|
||||
50.0, Achievement.AchievementType.DISTANCE_50K,
|
||||
100.0, Achievement.AchievementType.DISTANCE_100K,
|
||||
500.0, Achievement.AchievementType.DISTANCE_500K,
|
||||
1000.0, Achievement.AchievementType.DISTANCE_1000K
|
||||
);
|
||||
|
||||
for (Map.Entry<Double, Achievement.AchievementType> entry : milestones.entrySet()) {
|
||||
if (totalKm >= entry.getKey() && !hasAchievement(userId, entry.getValue())) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
entry.getValue(),
|
||||
String.format("%.0f km Total", entry.getKey()),
|
||||
String.format("Reached %.0f kilometers total distance!", entry.getKey()),
|
||||
"🏃",
|
||||
"#ffff00",
|
||||
null,
|
||||
Map.of("distance_km", entry.getKey())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check activity count milestone achievements.
|
||||
*/
|
||||
private List<Achievement> checkActivityCountMilestones(UUID userId) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
long activityCount = activityRepository.countByUserId(userId);
|
||||
|
||||
Map<Long, Achievement.AchievementType> milestones = Map.of(
|
||||
10L, Achievement.AchievementType.ACTIVITIES_10,
|
||||
50L, Achievement.AchievementType.ACTIVITIES_50,
|
||||
100L, Achievement.AchievementType.ACTIVITIES_100,
|
||||
500L, Achievement.AchievementType.ACTIVITIES_500,
|
||||
1000L, Achievement.AchievementType.ACTIVITIES_1000
|
||||
);
|
||||
|
||||
for (Map.Entry<Long, Achievement.AchievementType> entry : milestones.entrySet()) {
|
||||
if (activityCount >= entry.getKey() && !hasAchievement(userId, entry.getValue())) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
entry.getValue(),
|
||||
String.format("%d Activities", entry.getKey()),
|
||||
String.format("Completed %d activities!", entry.getKey()),
|
||||
"💪",
|
||||
"#ff6600",
|
||||
null,
|
||||
Map.of("activity_count", entry.getKey())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check streak achievements (consecutive days).
|
||||
*/
|
||||
private List<Achievement> checkStreakAchievements(UUID userId) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
int currentStreak = calculateCurrentStreak(userId);
|
||||
|
||||
Map<Integer, Achievement.AchievementType> streakMilestones = Map.of(
|
||||
7, Achievement.AchievementType.STREAK_7_DAYS,
|
||||
30, Achievement.AchievementType.STREAK_30_DAYS,
|
||||
100, Achievement.AchievementType.STREAK_100_DAYS,
|
||||
365, Achievement.AchievementType.STREAK_365_DAYS
|
||||
);
|
||||
|
||||
for (Map.Entry<Integer, Achievement.AchievementType> entry : streakMilestones.entrySet()) {
|
||||
if (currentStreak >= entry.getKey() && !hasAchievement(userId, entry.getValue())) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
entry.getValue(),
|
||||
String.format("%d Day Streak", entry.getKey()),
|
||||
String.format("Worked out %d days in a row!", entry.getKey()),
|
||||
"🔥",
|
||||
"#ff1493",
|
||||
null,
|
||||
Map.of("streak_days", entry.getKey())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check time-based achievements (early bird, night owl, weekend warrior).
|
||||
*/
|
||||
private List<Achievement> checkTimeBasedAchievements(UUID userId, Activity activity) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
LocalTime startTime = activity.getStartedAt().toLocalTime();
|
||||
|
||||
// Early bird (before 6am)
|
||||
if (startTime.isBefore(LocalTime.of(6, 0)) && !hasAchievement(userId, Achievement.AchievementType.EARLY_BIRD)) {
|
||||
long earlyActivities = activityRepository.countByUserIdAndStartTimeBefore(userId, LocalTime.of(6, 0));
|
||||
if (earlyActivities >= 5) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.EARLY_BIRD,
|
||||
"Early Bird",
|
||||
"Completed 5+ activities before 6am!",
|
||||
"🌅",
|
||||
"#ccff00",
|
||||
activity.getId(),
|
||||
Map.of("early_activities", earlyActivities)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Night owl (after 10pm)
|
||||
if (startTime.isAfter(LocalTime.of(22, 0)) && !hasAchievement(userId, Achievement.AchievementType.NIGHT_OWL)) {
|
||||
long lateActivities = activityRepository.countByUserIdAndStartTimeAfter(userId, LocalTime.of(22, 0));
|
||||
if (lateActivities >= 5) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.NIGHT_OWL,
|
||||
"Night Owl",
|
||||
"Completed 5+ activities after 10pm!",
|
||||
"🦉",
|
||||
"#9370db",
|
||||
activity.getId(),
|
||||
Map.of("late_activities", lateActivities)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check elevation achievements.
|
||||
*/
|
||||
private List<Achievement> checkElevationAchievements(UUID userId, Activity activity) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
// Single activity elevation
|
||||
if (activity.getElevationGain() != null &&
|
||||
activity.getElevationGain().compareTo(BigDecimal.valueOf(1000)) >= 0 &&
|
||||
!hasAchievement(userId, Achievement.AchievementType.MOUNTAINEER_1000M)) {
|
||||
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.MOUNTAINEER_1000M,
|
||||
"Mountaineer",
|
||||
"Climbed 1000m+ in a single activity!",
|
||||
"⛰️",
|
||||
"#8b4513",
|
||||
activity.getId(),
|
||||
Map.of("elevation_gain", activity.getElevationGain())
|
||||
));
|
||||
}
|
||||
|
||||
// Total elevation milestones
|
||||
BigDecimal totalElevation = activityRepository.sumElevationGainByUserId(userId);
|
||||
if (totalElevation != null) {
|
||||
double totalM = totalElevation.doubleValue();
|
||||
|
||||
if (totalM >= 5000 && !hasAchievement(userId, Achievement.AchievementType.MOUNTAINEER_5000M)) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.MOUNTAINEER_5000M,
|
||||
"Mountain Conqueror",
|
||||
"Climbed 5000m total elevation!",
|
||||
"🏔️",
|
||||
"#4169e1",
|
||||
null,
|
||||
Map.of("total_elevation", totalM)
|
||||
));
|
||||
}
|
||||
|
||||
if (totalM >= 10000 && !hasAchievement(userId, Achievement.AchievementType.MOUNTAINEER_10000M)) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.MOUNTAINEER_10000M,
|
||||
"Summit Master",
|
||||
"Climbed 10000m total elevation!",
|
||||
"🗻",
|
||||
"#1e90ff",
|
||||
null,
|
||||
Map.of("total_elevation", totalM)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check variety achievements.
|
||||
*/
|
||||
private List<Achievement> checkVarietyAchievements(UUID userId) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
long distinctActivityTypes = activityRepository.countDistinctActivityTypesByUserId(userId);
|
||||
|
||||
if (distinctActivityTypes >= 3 && !hasAchievement(userId, Achievement.AchievementType.VARIETY_SEEKER)) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.VARIETY_SEEKER,
|
||||
"Variety Seeker",
|
||||
"Tried 3+ different activity types!",
|
||||
"🌈",
|
||||
"#ff69b4",
|
||||
null,
|
||||
Map.of("activity_types", distinctActivityTypes)
|
||||
));
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check speed achievements.
|
||||
*/
|
||||
private List<Achievement> checkSpeedAchievements(UUID userId, Activity activity) {
|
||||
List<Achievement> achievements = new ArrayList<>();
|
||||
|
||||
if (activity.getMetrics() != null && activity.getMetrics().getMaxSpeed() != null) {
|
||||
// Convert m/s to km/h
|
||||
double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue() * 3.6;
|
||||
|
||||
if (maxSpeedKmh >= 50 && !hasAchievement(userId, Achievement.AchievementType.SPEED_DEMON)) {
|
||||
achievements.add(awardAchievement(
|
||||
userId,
|
||||
Achievement.AchievementType.SPEED_DEMON,
|
||||
"Speed Demon",
|
||||
"Reached 50+ km/h!",
|
||||
"⚡",
|
||||
"#ffd700",
|
||||
activity.getId(),
|
||||
Map.of("max_speed_kmh", maxSpeedKmh)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate current activity streak (consecutive days).
|
||||
*/
|
||||
private int calculateCurrentStreak(UUID userId) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate checkDate = today;
|
||||
int streak = 0;
|
||||
|
||||
// Check backwards from today
|
||||
for (int i = 0; i < 365; i++) { // Max check 1 year
|
||||
boolean hasActivity = activityRepository.existsByUserIdAndDate(userId, checkDate);
|
||||
|
||||
if (hasActivity) {
|
||||
streak++;
|
||||
checkDate = checkDate.minusDays(1);
|
||||
} else {
|
||||
// Allow one rest day if we already have a streak
|
||||
if (streak > 0 && i > 0) {
|
||||
checkDate = checkDate.minusDays(1);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has already earned an achievement.
|
||||
*/
|
||||
private boolean hasAchievement(UUID userId, Achievement.AchievementType achievementType) {
|
||||
return achievementRepository.existsByUserIdAndAchievementType(userId, achievementType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Award an achievement to a user.
|
||||
*/
|
||||
private Achievement awardAchievement(UUID userId, Achievement.AchievementType achievementType,
|
||||
String name, String description, String icon, String color,
|
||||
UUID activityId, Map<String, Object> metadata) {
|
||||
Achievement achievement = Achievement.builder()
|
||||
.userId(userId)
|
||||
.achievementType(achievementType)
|
||||
.name(name)
|
||||
.description(description)
|
||||
.badgeIcon(icon)
|
||||
.badgeColor(color)
|
||||
.earnedAt(LocalDateTime.now())
|
||||
.activityId(activityId)
|
||||
.metadata(metadata)
|
||||
.build();
|
||||
|
||||
achievementRepository.save(achievement);
|
||||
log.info("Achievement earned: {} by user {}", name, userId);
|
||||
return achievement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji for activity type.
|
||||
*/
|
||||
private String getActivityEmoji(String activityType) {
|
||||
return switch (activityType.toUpperCase()) {
|
||||
case "RUN" -> "🏃";
|
||||
case "RIDE" -> "🚴";
|
||||
case "HIKE" -> "🥾";
|
||||
case "WALK" -> "🚶";
|
||||
case "SWIM" -> "🏊";
|
||||
default -> "💪";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all achievements for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Achievement> getUserAchievements(UUID userId) {
|
||||
return achievementRepository.findByUserIdOrderByEarnedAtDesc(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get achievement count for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long getAchievementCount(UUID userId) {
|
||||
return achievementRepository.countByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivitySummary;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.ActivitySummaryRepository;
|
||||
import org.operaton.fitpub.repository.AchievementRepository;
|
||||
import org.operaton.fitpub.repository.PersonalRecordRepository;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for generating and managing activity summaries (weekly/monthly/yearly).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ActivitySummaryService {
|
||||
|
||||
private final ActivitySummaryRepository activitySummaryRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final PersonalRecordRepository personalRecordRepository;
|
||||
private final AchievementRepository achievementRepository;
|
||||
|
||||
/**
|
||||
* Update summary for an activity's period.
|
||||
* Called after an activity is saved.
|
||||
*/
|
||||
@Async
|
||||
@Transactional
|
||||
public void updateSummariesForActivity(Activity activity) {
|
||||
if (activity.getUserId() == null || activity.getStartedAt() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDate activityDate = activity.getStartedAt().toLocalDate();
|
||||
UUID userId = activity.getUserId();
|
||||
|
||||
// Update weekly summary
|
||||
updateWeeklySummary(userId, activityDate);
|
||||
|
||||
// Update monthly summary
|
||||
updateMonthlySummary(userId, activityDate);
|
||||
|
||||
// Update yearly summary
|
||||
updateYearlySummary(userId, activityDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update weekly summary.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateWeeklySummary(UUID userId, LocalDate date) {
|
||||
LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||
LocalDate weekEnd = weekStart.plusDays(6);
|
||||
|
||||
ActivitySummary summary = activitySummaryRepository
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart)
|
||||
.orElse(ActivitySummary.builder()
|
||||
.userId(userId)
|
||||
.periodType(ActivitySummary.PeriodType.WEEK)
|
||||
.periodStart(weekStart)
|
||||
.periodEnd(weekEnd)
|
||||
.build());
|
||||
|
||||
calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay());
|
||||
activitySummaryRepository.save(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update monthly summary.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateMonthlySummary(UUID userId, LocalDate date) {
|
||||
LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth());
|
||||
LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth());
|
||||
|
||||
ActivitySummary summary = activitySummaryRepository
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart)
|
||||
.orElse(ActivitySummary.builder()
|
||||
.userId(userId)
|
||||
.periodType(ActivitySummary.PeriodType.MONTH)
|
||||
.periodStart(monthStart)
|
||||
.periodEnd(monthEnd)
|
||||
.build());
|
||||
|
||||
calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay());
|
||||
activitySummaryRepository.save(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update yearly summary.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateYearlySummary(UUID userId, LocalDate date) {
|
||||
LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear());
|
||||
LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear());
|
||||
|
||||
ActivitySummary summary = activitySummaryRepository
|
||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart)
|
||||
.orElse(ActivitySummary.builder()
|
||||
.userId(userId)
|
||||
.periodType(ActivitySummary.PeriodType.YEAR)
|
||||
.periodStart(yearStart)
|
||||
.periodEnd(yearEnd)
|
||||
.build());
|
||||
|
||||
calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay());
|
||||
activitySummaryRepository.save(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and update summary statistics.
|
||||
*/
|
||||
private void calculateAndUpdateSummary(ActivitySummary summary, UUID userId,
|
||||
LocalDateTime startDateTime, LocalDateTime endDateTime) {
|
||||
// Get activities in period
|
||||
List<Activity> activities = activityRepository
|
||||
.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(userId, startDateTime, endDateTime);
|
||||
|
||||
// Calculate totals
|
||||
int activityCount = activities.size();
|
||||
long totalDuration = activities.stream()
|
||||
.mapToLong(a -> a.getTotalDurationSeconds() != null ? a.getTotalDurationSeconds() : 0)
|
||||
.sum();
|
||||
BigDecimal totalDistance = activities.stream()
|
||||
.map(a -> a.getTotalDistance() != null ? a.getTotalDistance() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal totalElevation = activities.stream()
|
||||
.map(a -> a.getElevationGain() != null ? a.getElevationGain() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// Calculate max speed
|
||||
BigDecimal maxSpeed = activities.stream()
|
||||
.filter(a -> a.getMetrics() != null && a.getMetrics().getMaxSpeed() != null)
|
||||
.map(a -> a.getMetrics().getMaxSpeed())
|
||||
.max(BigDecimal::compareTo)
|
||||
.orElse(null);
|
||||
|
||||
// Calculate average speed
|
||||
BigDecimal avgSpeed = null;
|
||||
if (totalDuration > 0 && totalDistance.compareTo(BigDecimal.ZERO) > 0) {
|
||||
avgSpeed = totalDistance.divide(BigDecimal.valueOf(totalDuration), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
// Activity type breakdown
|
||||
Map<String, Integer> typeBreakdown = new HashMap<>();
|
||||
for (Activity activity : activities) {
|
||||
String type = activity.getActivityType().name();
|
||||
typeBreakdown.put(type, typeBreakdown.getOrDefault(type, 0) + 1);
|
||||
}
|
||||
|
||||
// Count PRs and achievements in this period
|
||||
long prsSet = personalRecordRepository.countByUserIdAndDateRange(
|
||||
userId,
|
||||
startDateTime,
|
||||
endDateTime
|
||||
);
|
||||
long achievementsEarned = achievementRepository.countByUserIdAndDateRange(
|
||||
userId,
|
||||
startDateTime,
|
||||
endDateTime
|
||||
);
|
||||
|
||||
// Update summary
|
||||
summary.setActivityCount(activityCount);
|
||||
summary.setTotalDurationSeconds(totalDuration);
|
||||
summary.setTotalDistanceMeters(totalDistance);
|
||||
summary.setTotalElevationGainMeters(totalElevation);
|
||||
summary.setMaxSpeedMps(maxSpeed);
|
||||
summary.setAvgSpeedMps(avgSpeed);
|
||||
summary.setActivityTypeBreakdown(typeBreakdown);
|
||||
summary.setPersonalRecordsSet((int) prsSet);
|
||||
summary.setAchievementsEarned((int) achievementsEarned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekly summaries for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivitySummary> getWeeklySummaries(UUID userId, int weeks) {
|
||||
return activitySummaryRepository.findRecentByUserIdAndPeriodType(
|
||||
userId,
|
||||
ActivitySummary.PeriodType.WEEK,
|
||||
weeks
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly summaries for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivitySummary> getMonthlySummaries(UUID userId, int months) {
|
||||
return activitySummaryRepository.findRecentByUserIdAndPeriodType(
|
||||
userId,
|
||||
ActivitySummary.PeriodType.MONTH,
|
||||
months
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get yearly summaries for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivitySummary> getYearlySummaries(UUID userId, int years) {
|
||||
return activitySummaryRepository.findRecentByUserIdAndPeriodType(
|
||||
userId,
|
||||
ActivitySummary.PeriodType.YEAR,
|
||||
years
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current week summary.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ActivitySummary getCurrentWeekSummary(UUID userId) {
|
||||
LocalDate weekStart = LocalDate.now().with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||
return activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
|
||||
userId,
|
||||
ActivitySummary.PeriodType.WEEK,
|
||||
weekStart
|
||||
).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current month summary.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ActivitySummary getCurrentMonthSummary(UUID userId) {
|
||||
LocalDate monthStart = LocalDate.now().with(TemporalAdjusters.firstDayOfMonth());
|
||||
return activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
|
||||
userId,
|
||||
ActivitySummary.PeriodType.MONTH,
|
||||
monthStart
|
||||
).orElse(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,10 @@ public class FitFileService {
|
|||
private final ActivityRepository activityRepository;
|
||||
private final ActivityMetricsRepository metricsRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PersonalRecordService personalRecordService;
|
||||
private final AchievementService achievementService;
|
||||
private final TrainingLoadService trainingLoadService;
|
||||
private final ActivitySummaryService activitySummaryService;
|
||||
|
||||
/**
|
||||
* Processes an uploaded FIT file and creates an activity.
|
||||
|
|
@ -104,6 +108,14 @@ public class FitFileService {
|
|||
parsedData.getTrackPoints().size(),
|
||||
simplifiedTrack.getNumPoints());
|
||||
|
||||
// Check for personal records and achievements
|
||||
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
|
||||
achievementService.checkAndAwardAchievements(savedActivity);
|
||||
|
||||
// Update training load and summaries (async)
|
||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
activitySummaryService.updateSummariesForActivity(savedActivity);
|
||||
|
||||
return savedActivity;
|
||||
} catch (IOException e) {
|
||||
throw new FitFileProcessingException("Failed to read FIT file", e);
|
||||
|
|
@ -185,7 +197,15 @@ public class FitFileService {
|
|||
activity.setMetrics(metrics);
|
||||
}
|
||||
|
||||
return activityRepository.save(activity);
|
||||
Activity savedActivity = activityRepository.save(activity);
|
||||
|
||||
// Update analytics
|
||||
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
|
||||
achievementService.checkAndAwardAchievements(savedActivity);
|
||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
activitySummaryService.updateSummariesForActivity(savedActivity);
|
||||
|
||||
return savedActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -134,11 +134,38 @@ public class OsmTileRenderer {
|
|||
Math.min(cropHeight, fullHeight - cropY)
|
||||
);
|
||||
|
||||
// Scale to target dimensions
|
||||
// Scale to target dimensions with letterboxing to preserve aspect ratio
|
||||
BufferedImage scaledMap = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = scaledMap.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.drawImage(croppedMap, 0, 0, width, height, null);
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
|
||||
// Calculate aspect ratios
|
||||
double sourceAspect = (double) croppedMap.getWidth() / croppedMap.getHeight();
|
||||
double targetAspect = (double) width / height;
|
||||
|
||||
int drawWidth, drawHeight, drawX, drawY;
|
||||
|
||||
if (sourceAspect > targetAspect) {
|
||||
// Source is wider - fit to width, letterbox top/bottom
|
||||
drawWidth = width;
|
||||
drawHeight = (int) (width / sourceAspect);
|
||||
drawX = 0;
|
||||
drawY = (height - drawHeight) / 2;
|
||||
} else {
|
||||
// Source is taller - fit to height, letterbox left/right
|
||||
drawHeight = height;
|
||||
drawWidth = (int) (height * sourceAspect);
|
||||
drawX = (width - drawWidth) / 2;
|
||||
drawY = 0;
|
||||
}
|
||||
|
||||
// Fill background with neutral gray
|
||||
g.setColor(new Color(240, 240, 240));
|
||||
g.fillRect(0, 0, width, height);
|
||||
|
||||
// Draw scaled image centered with preserved aspect ratio
|
||||
g.drawImage(croppedMap, drawX, drawY, drawWidth, drawHeight, null);
|
||||
g.dispose();
|
||||
|
||||
return scaledMap;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.PersonalRecord;
|
||||
import org.operaton.fitpub.repository.PersonalRecordRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for tracking and managing personal records.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PersonalRecordService {
|
||||
|
||||
private final PersonalRecordRepository personalRecordRepository;
|
||||
|
||||
/**
|
||||
* Check and update personal records for an activity.
|
||||
* Called after an activity is saved or updated.
|
||||
*
|
||||
* @param activity the activity to check for PRs
|
||||
* @return list of new personal records set
|
||||
*/
|
||||
@Transactional
|
||||
public List<PersonalRecord> checkAndUpdatePersonalRecords(Activity activity) {
|
||||
List<PersonalRecord> newRecords = new java.util.ArrayList<>();
|
||||
|
||||
if (activity.getUserId() == null) {
|
||||
return newRecords;
|
||||
}
|
||||
|
||||
PersonalRecord.ActivityType activityType = mapActivityType(activity.getActivityType().name());
|
||||
|
||||
// Check longest distance
|
||||
if (activity.getTotalDistance() != null) {
|
||||
PersonalRecord distanceRecord = checkRecord(
|
||||
activity.getUserId(),
|
||||
activityType,
|
||||
PersonalRecord.RecordType.LONGEST_DISTANCE,
|
||||
activity.getTotalDistance(),
|
||||
"meters",
|
||||
activity.getId(),
|
||||
activity.getStartedAt()
|
||||
);
|
||||
if (distanceRecord != null) {
|
||||
newRecords.add(distanceRecord);
|
||||
}
|
||||
|
||||
// Check specific distance PRs (5K, 10K, etc.) for runs
|
||||
if (activityType == PersonalRecord.ActivityType.RUN) {
|
||||
newRecords.addAll(checkDistancePRs(activity));
|
||||
}
|
||||
}
|
||||
|
||||
// Check longest duration
|
||||
if (activity.getTotalDurationSeconds() != null) {
|
||||
PersonalRecord durationRecord = checkRecord(
|
||||
activity.getUserId(),
|
||||
activityType,
|
||||
PersonalRecord.RecordType.LONGEST_DURATION,
|
||||
BigDecimal.valueOf(activity.getTotalDurationSeconds()),
|
||||
"seconds",
|
||||
activity.getId(),
|
||||
activity.getStartedAt()
|
||||
);
|
||||
if (durationRecord != null) {
|
||||
newRecords.add(durationRecord);
|
||||
}
|
||||
}
|
||||
|
||||
// Check highest elevation gain
|
||||
if (activity.getElevationGain() != null) {
|
||||
PersonalRecord elevationRecord = checkRecord(
|
||||
activity.getUserId(),
|
||||
activityType,
|
||||
PersonalRecord.RecordType.HIGHEST_ELEVATION_GAIN,
|
||||
activity.getElevationGain(),
|
||||
"meters",
|
||||
activity.getId(),
|
||||
activity.getStartedAt()
|
||||
);
|
||||
if (elevationRecord != null) {
|
||||
newRecords.add(elevationRecord);
|
||||
}
|
||||
}
|
||||
|
||||
// Check max speed (from metrics)
|
||||
if (activity.getMetrics() != null && activity.getMetrics().getMaxSpeed() != null) {
|
||||
PersonalRecord maxSpeedRecord = checkRecord(
|
||||
activity.getUserId(),
|
||||
activityType,
|
||||
PersonalRecord.RecordType.MAX_SPEED,
|
||||
activity.getMetrics().getMaxSpeed(),
|
||||
"mps",
|
||||
activity.getId(),
|
||||
activity.getStartedAt()
|
||||
);
|
||||
if (maxSpeedRecord != null) {
|
||||
newRecords.add(maxSpeedRecord);
|
||||
}
|
||||
}
|
||||
|
||||
// Check best average pace (for runs)
|
||||
if (activityType == PersonalRecord.ActivityType.RUN &&
|
||||
activity.getTotalDistance() != null &&
|
||||
activity.getTotalDurationSeconds() != null &&
|
||||
activity.getTotalDistance().compareTo(BigDecimal.valueOf(1000)) >= 0) { // At least 1km
|
||||
|
||||
BigDecimal avgPace = BigDecimal.valueOf(activity.getTotalDurationSeconds())
|
||||
.divide(activity.getTotalDistance().divide(BigDecimal.valueOf(1000), 2, RoundingMode.HALF_UP), 2, RoundingMode.HALF_UP);
|
||||
|
||||
// Lower pace is better (faster)
|
||||
PersonalRecord paceRecord = checkRecordLowerIsBetter(
|
||||
activity.getUserId(),
|
||||
activityType,
|
||||
PersonalRecord.RecordType.BEST_AVERAGE_PACE,
|
||||
avgPace,
|
||||
"seconds_per_km",
|
||||
activity.getId(),
|
||||
activity.getStartedAt()
|
||||
);
|
||||
if (paceRecord != null) {
|
||||
newRecords.add(paceRecord);
|
||||
}
|
||||
}
|
||||
|
||||
return newRecords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check specific distance PRs (1K, 5K, 10K, half marathon, marathon).
|
||||
*/
|
||||
private List<PersonalRecord> checkDistancePRs(Activity activity) {
|
||||
List<PersonalRecord> records = new java.util.ArrayList<>();
|
||||
|
||||
if (activity.getTotalDistance() == null || activity.getTotalDurationSeconds() == null) {
|
||||
return records;
|
||||
}
|
||||
|
||||
double distanceKm = activity.getTotalDistance().doubleValue() / 1000.0;
|
||||
long durationSeconds = activity.getTotalDurationSeconds();
|
||||
|
||||
// Define target distances and their record types
|
||||
record DistanceTarget(double km, PersonalRecord.RecordType recordType) {}
|
||||
List<DistanceTarget> targets = List.of(
|
||||
new DistanceTarget(1.0, PersonalRecord.RecordType.FASTEST_1K),
|
||||
new DistanceTarget(5.0, PersonalRecord.RecordType.FASTEST_5K),
|
||||
new DistanceTarget(10.0, PersonalRecord.RecordType.FASTEST_10K),
|
||||
new DistanceTarget(21.1, PersonalRecord.RecordType.FASTEST_HALF_MARATHON),
|
||||
new DistanceTarget(42.2, PersonalRecord.RecordType.FASTEST_MARATHON)
|
||||
);
|
||||
|
||||
for (DistanceTarget target : targets) {
|
||||
// Activity must be at least the target distance (within 5% tolerance)
|
||||
if (distanceKm >= target.km * 0.95) {
|
||||
// Calculate time for target distance (proportional)
|
||||
double timeForDistance = durationSeconds * (target.km / distanceKm);
|
||||
BigDecimal time = BigDecimal.valueOf(timeForDistance).setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
// Lower time is better
|
||||
PersonalRecord record = checkRecordLowerIsBetter(
|
||||
activity.getUserId(),
|
||||
PersonalRecord.ActivityType.RUN,
|
||||
target.recordType,
|
||||
time,
|
||||
"seconds",
|
||||
activity.getId(),
|
||||
activity.getStartedAt()
|
||||
);
|
||||
if (record != null) {
|
||||
records.add(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a new record has been set (higher is better).
|
||||
*/
|
||||
private PersonalRecord checkRecord(UUID userId, PersonalRecord.ActivityType activityType,
|
||||
PersonalRecord.RecordType recordType, BigDecimal value,
|
||||
String unit, UUID activityId, LocalDateTime achievedAt) {
|
||||
Optional<PersonalRecord> existingRecord = personalRecordRepository
|
||||
.findByUserIdAndActivityTypeAndRecordType(userId, activityType, recordType);
|
||||
|
||||
if (existingRecord.isEmpty() || value.compareTo(existingRecord.get().getValue()) > 0) {
|
||||
PersonalRecord newRecord = PersonalRecord.builder()
|
||||
.userId(userId)
|
||||
.activityType(activityType)
|
||||
.recordType(recordType)
|
||||
.value(value)
|
||||
.unit(unit)
|
||||
.activityId(activityId)
|
||||
.achievedAt(achievedAt)
|
||||
.build();
|
||||
|
||||
existingRecord.ifPresent(record -> {
|
||||
newRecord.setPreviousValue(record.getValue());
|
||||
newRecord.setPreviousAchievedAt(record.getAchievedAt());
|
||||
});
|
||||
|
||||
personalRecordRepository.save(newRecord);
|
||||
log.info("New personal record set: {} {} - {} {}", activityType, recordType, value, unit);
|
||||
return newRecord;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a new record has been set (lower is better, e.g., pace, time).
|
||||
*/
|
||||
private PersonalRecord checkRecordLowerIsBetter(UUID userId, PersonalRecord.ActivityType activityType,
|
||||
PersonalRecord.RecordType recordType, BigDecimal value,
|
||||
String unit, UUID activityId, LocalDateTime achievedAt) {
|
||||
Optional<PersonalRecord> existingRecord = personalRecordRepository
|
||||
.findByUserIdAndActivityTypeAndRecordType(userId, activityType, recordType);
|
||||
|
||||
if (existingRecord.isEmpty() || value.compareTo(existingRecord.get().getValue()) < 0) {
|
||||
PersonalRecord newRecord = PersonalRecord.builder()
|
||||
.userId(userId)
|
||||
.activityType(activityType)
|
||||
.recordType(recordType)
|
||||
.value(value)
|
||||
.unit(unit)
|
||||
.activityId(activityId)
|
||||
.achievedAt(achievedAt)
|
||||
.build();
|
||||
|
||||
existingRecord.ifPresent(record -> {
|
||||
newRecord.setPreviousValue(record.getValue());
|
||||
newRecord.setPreviousAchievedAt(record.getAchievedAt());
|
||||
});
|
||||
|
||||
personalRecordRepository.save(newRecord);
|
||||
log.info("New personal record set: {} {} - {} {}", activityType, recordType, value, unit);
|
||||
return newRecord;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Activity.ActivityType to PersonalRecord.ActivityType.
|
||||
*/
|
||||
private PersonalRecord.ActivityType mapActivityType(String activityType) {
|
||||
if (activityType == null) {
|
||||
return PersonalRecord.ActivityType.OTHER;
|
||||
}
|
||||
|
||||
return switch (activityType.toUpperCase()) {
|
||||
case "RUN", "RUNNING" -> PersonalRecord.ActivityType.RUN;
|
||||
case "RIDE", "CYCLING", "BIKE" -> PersonalRecord.ActivityType.RIDE;
|
||||
case "HIKE", "HIKING" -> PersonalRecord.ActivityType.HIKE;
|
||||
case "WALK", "WALKING" -> PersonalRecord.ActivityType.WALK;
|
||||
case "SWIM", "SWIMMING" -> PersonalRecord.ActivityType.SWIM;
|
||||
default -> PersonalRecord.ActivityType.OTHER;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all personal records for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<PersonalRecord> getPersonalRecords(UUID userId) {
|
||||
return personalRecordRepository.findByUserIdOrderByAchievedAtDesc(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personal records for a user filtered by activity type.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<PersonalRecord> getPersonalRecordsByType(UUID userId, PersonalRecord.ActivityType activityType) {
|
||||
return personalRecordRepository.findByUserIdAndActivityTypeOrderByAchievedAtDesc(userId, activityType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of personal records for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long getPersonalRecordCount(UUID userId) {
|
||||
return personalRecordRepository.countByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.TrainingLoad;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.TrainingLoadRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for calculating and managing training load metrics.
|
||||
* Implements Training Stress Score (TSS) and load balancing algorithms.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TrainingLoadService {
|
||||
|
||||
private final TrainingLoadRepository trainingLoadRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
|
||||
/**
|
||||
* Update training load for an activity.
|
||||
* Called after an activity is saved.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateTrainingLoad(Activity activity) {
|
||||
if (activity.getUserId() == null || activity.getStartedAt() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDate activityDate = activity.getStartedAt().toLocalDate();
|
||||
updateDailyTrainingLoad(activity.getUserId(), activityDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update daily training load for a specific date.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateDailyTrainingLoad(UUID userId, LocalDate date) {
|
||||
// Get or create training load entry
|
||||
TrainingLoad trainingLoad = trainingLoadRepository
|
||||
.findByUserIdAndDate(userId, date)
|
||||
.orElse(TrainingLoad.builder()
|
||||
.userId(userId)
|
||||
.date(date)
|
||||
.build());
|
||||
|
||||
// Get all activities for this date
|
||||
LocalDateTime startOfDay = date.atStartOfDay();
|
||||
LocalDateTime endOfDay = date.plusDays(1).atStartOfDay();
|
||||
List<Activity> activities = activityRepository
|
||||
.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(userId, startOfDay, endOfDay);
|
||||
|
||||
// Calculate daily totals
|
||||
int activityCount = activities.size();
|
||||
long totalDuration = activities.stream()
|
||||
.mapToLong(a -> a.getTotalDurationSeconds() != null ? a.getTotalDurationSeconds() : 0)
|
||||
.sum();
|
||||
BigDecimal totalDistance = activities.stream()
|
||||
.map(a -> a.getTotalDistance() != null ? a.getTotalDistance() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal totalElevation = activities.stream()
|
||||
.map(a -> a.getElevationGain() != null ? a.getElevationGain() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// Calculate Training Stress Score (simplified algorithm)
|
||||
BigDecimal tss = calculateTrainingStressScore(totalDuration, totalDistance, totalElevation);
|
||||
|
||||
trainingLoad.setActivityCount(activityCount);
|
||||
trainingLoad.setTotalDurationSeconds(totalDuration);
|
||||
trainingLoad.setTotalDistanceMeters(totalDistance);
|
||||
trainingLoad.setTotalElevationGainMeters(totalElevation);
|
||||
trainingLoad.setTrainingStressScore(tss);
|
||||
|
||||
// Calculate rolling averages
|
||||
calculateRollingAverages(trainingLoad, userId, date);
|
||||
|
||||
trainingLoadRepository.save(trainingLoad);
|
||||
log.debug("Updated training load for user {} on {}: TSS={}", userId, date, tss);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Training Stress Score (simplified).
|
||||
* TSS = (duration_hours * intensity_factor * 100)
|
||||
*/
|
||||
private BigDecimal calculateTrainingStressScore(long durationSeconds, BigDecimal distanceMeters, BigDecimal elevationMeters) {
|
||||
if (durationSeconds == 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
double durationHours = durationSeconds / 3600.0;
|
||||
double distance = distanceMeters.doubleValue();
|
||||
double elevation = elevationMeters.doubleValue();
|
||||
|
||||
// Intensity factor based on distance/time ratio and elevation
|
||||
double speed = distance / durationSeconds; // m/s
|
||||
double intensityFactor = Math.min(1.0, speed / 3.0); // Normalize to ~3 m/s baseline
|
||||
|
||||
// Elevation bonus (1m elevation = ~10m distance in terms of effort)
|
||||
double elevationBonus = elevation / 10.0;
|
||||
double effectiveDuration = durationHours * (1.0 + (elevationBonus / distance));
|
||||
|
||||
// TSS = duration * intensity * 100
|
||||
double tss = effectiveDuration * intensityFactor * 100.0;
|
||||
|
||||
return BigDecimal.valueOf(tss).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rolling averages (ATL, CTL, TSB).
|
||||
*/
|
||||
private void calculateRollingAverages(TrainingLoad trainingLoad, UUID userId, LocalDate date) {
|
||||
// Get last 7 days for ATL (Acute Training Load)
|
||||
LocalDate sevenDaysAgo = date.minusDays(6);
|
||||
List<TrainingLoad> last7Days = trainingLoadRepository
|
||||
.findByUserIdAndDateBetweenOrderByDateDesc(userId, sevenDaysAgo, date);
|
||||
|
||||
BigDecimal atl = last7Days.stream()
|
||||
.map(tl -> tl.getTrainingStressScore() != null ? tl.getTrainingStressScore() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.divide(BigDecimal.valueOf(7), 2, RoundingMode.HALF_UP);
|
||||
|
||||
// Get last 28 days for CTL (Chronic Training Load)
|
||||
LocalDate twentyEightDaysAgo = date.minusDays(27);
|
||||
List<TrainingLoad> last28Days = trainingLoadRepository
|
||||
.findByUserIdAndDateBetweenOrderByDateDesc(userId, twentyEightDaysAgo, date);
|
||||
|
||||
BigDecimal ctl = last28Days.stream()
|
||||
.map(tl -> tl.getTrainingStressScore() != null ? tl.getTrainingStressScore() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.divide(BigDecimal.valueOf(28), 2, RoundingMode.HALF_UP);
|
||||
|
||||
// TSB = CTL - ATL (Training Stress Balance)
|
||||
BigDecimal tsb = ctl.subtract(atl);
|
||||
|
||||
trainingLoad.setAcuteTrainingLoad(atl);
|
||||
trainingLoad.setChronicTrainingLoad(ctl);
|
||||
trainingLoad.setTrainingStressBalance(tsb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get training load for a user over a date range.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<TrainingLoad> getTrainingLoad(UUID userId, LocalDate startDate, LocalDate endDate) {
|
||||
return trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent training load (last 30 days).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<TrainingLoad> getRecentTrainingLoad(UUID userId, int days) {
|
||||
LocalDate startDate = LocalDate.now().minusDays(days - 1);
|
||||
return trainingLoadRepository.findByUserIdSinceDate(userId, startDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current form status for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public TrainingLoad.FormStatus getCurrentFormStatus(UUID userId) {
|
||||
return trainingLoadRepository.findFirstByUserIdOrderByDateDesc(userId)
|
||||
.map(TrainingLoad::getFormStatus)
|
||||
.orElse(TrainingLoad.FormStatus.UNKNOWN);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
267
src/main/resources/templates/analytics/achievements.html
Normal file
267
src/main/resources/templates/analytics/achievements.html
Normal 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>
|
||||
349
src/main/resources/templates/analytics/dashboard.html
Normal file
349
src/main/resources/templates/analytics/dashboard.html
Normal 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>
|
||||
277
src/main/resources/templates/analytics/personal-records.html
Normal file
277
src/main/resources/templates/analytics/personal-records.html
Normal 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>
|
||||
264
src/main/resources/templates/analytics/summaries.html
Normal file
264
src/main/resources/templates/analytics/summaries.html
Normal 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>
|
||||
291
src/main/resources/templates/analytics/training-load.html
Normal file
291
src/main/resources/templates/analytics/training-load.html
Normal 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>
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue