diff --git a/CLAUDE.md b/CLAUDE.md index 25171d3..78b50ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/img.png b/img.png deleted file mode 100644 index a216405..0000000 Binary files a/img.png and /dev/null differ diff --git a/src/main/java/org/operaton/fitpub/controller/AnalyticsController.java b/src/main/java/org/operaton/fitpub/controller/AnalyticsController.java new file mode 100644 index 0000000..c92790e --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/AnalyticsController.java @@ -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> getDashboard( + @AuthenticationPrincipal UserDetails userDetails) { + + UUID userId = getUserId(userDetails); + + Map 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 recentPRs = personalRecordService.getPersonalRecords(userId) + .stream() + .limit(5) + .toList(); + dashboard.put("recentPersonalRecords", recentPRs); + + // Recent achievements (last 5) + List 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> getPersonalRecords( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(required = false) String activityType) { + + UUID userId = getUserId(userDetails); + + List 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> getAchievements( + @AuthenticationPrincipal UserDetails userDetails) { + + UUID userId = getUserId(userDetails); + List achievements = achievementService.getUserAchievements(userId); + + return ResponseEntity.ok(achievements); + } + + /** + * Get training load for a date range. + */ + @GetMapping("/training-load") + public ResponseEntity> getTrainingLoad( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(required = false) Integer days) { + + UUID userId = getUserId(userDetails); + + List 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> 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 = trainingLoadService.getTrainingLoad(userId, start, end); + + return ResponseEntity.ok(trainingLoad); + } + + /** + * Get current form status. + */ + @GetMapping("/form-status") + public ResponseEntity> getFormStatus( + @AuthenticationPrincipal UserDetails userDetails) { + + UUID userId = getUserId(userDetails); + TrainingLoad.FormStatus formStatus = trainingLoadService.getCurrentFormStatus(userId); + + Map response = new HashMap<>(); + response.put("formStatus", formStatus); + response.put("description", getFormStatusDescription(formStatus)); + + return ResponseEntity.ok(response); + } + + /** + * Get weekly summaries. + */ + @GetMapping("/summaries/weekly") + public ResponseEntity> getWeeklySummaries( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(defaultValue = "12") int weeks) { + + UUID userId = getUserId(userDetails); + List summaries = activitySummaryService.getWeeklySummaries(userId, weeks); + + return ResponseEntity.ok(summaries); + } + + /** + * Get monthly summaries. + */ + @GetMapping("/summaries/monthly") + public ResponseEntity> getMonthlySummaries( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(defaultValue = "12") int months) { + + UUID userId = getUserId(userDetails); + List summaries = activitySummaryService.getMonthlySummaries(userId, months); + + return ResponseEntity.ok(summaries); + } + + /** + * Get yearly summaries. + */ + @GetMapping("/summaries/yearly") + public ResponseEntity> getYearlySummaries( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(defaultValue = "5") int years) { + + UUID userId = getUserId(userDetails); + List summaries = activitySummaryService.getYearlySummaries(userId, years); + + return ResponseEntity.ok(summaries); + } + + /** + * Get current week summary. + */ + @GetMapping("/summaries/current-week") + public ResponseEntity 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 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."; + }; + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/AnalyticsViewController.java b/src/main/java/org/operaton/fitpub/controller/AnalyticsViewController.java new file mode 100644 index 0000000..c9f8264 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/AnalyticsViewController.java @@ -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"; + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/Achievement.java b/src/main/java/org/operaton/fitpub/model/entity/Achievement.java new file mode 100644 index 0000000..ad100ba --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/Achievement.java @@ -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 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 + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/ActivitySummary.java b/src/main/java/org/operaton/fitpub/model/entity/ActivitySummary.java new file mode 100644 index 0000000..3fa2e64 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/ActivitySummary.java @@ -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 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 + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/PersonalRecord.java b/src/main/java/org/operaton/fitpub/model/entity/PersonalRecord.java new file mode 100644 index 0000000..b8341f5 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/PersonalRecord.java @@ -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 + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/TrainingLoad.java b/src/main/java/org/operaton/fitpub/model/entity/TrainingLoad.java new file mode 100644 index 0000000..b99dc2b --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/TrainingLoad.java @@ -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 + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/AchievementRepository.java b/src/main/java/org/operaton/fitpub/repository/AchievementRepository.java new file mode 100644 index 0000000..b9f5bdb --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/AchievementRepository.java @@ -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 { + + /** + * Find all achievements for a user, ordered by most recent first. + */ + List findByUserIdOrderByEarnedAtDesc(UUID userId); + + /** + * Find a specific achievement by user and type. + */ + Optional 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 findRecentByUserId(@Param("userId") UUID userId, @Param("limit") int limit); +} diff --git a/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java b/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java index b2a1ce8..b9d61f1 100644 --- a/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java +++ b/src/main/java/org/operaton/fitpub/repository/ActivityRepository.java @@ -139,4 +139,49 @@ public interface ActivityRepository extends JpaRepository { 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); } diff --git a/src/main/java/org/operaton/fitpub/repository/ActivitySummaryRepository.java b/src/main/java/org/operaton/fitpub/repository/ActivitySummaryRepository.java new file mode 100644 index 0000000..c0be1e4 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/ActivitySummaryRepository.java @@ -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 { + + /** + * Find a specific summary by user, period type, and start date. + */ + Optional findByUserIdAndPeriodTypeAndPeriodStart( + UUID userId, + ActivitySummary.PeriodType periodType, + LocalDate periodStart + ); + + /** + * Find summaries for a user by period type, ordered by most recent first. + */ + List 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 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 findByUserIdAndPeriodTypeAndDateRange( + @Param("userId") UUID userId, + @Param("periodType") ActivitySummary.PeriodType periodType, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); +} diff --git a/src/main/java/org/operaton/fitpub/repository/PersonalRecordRepository.java b/src/main/java/org/operaton/fitpub/repository/PersonalRecordRepository.java new file mode 100644 index 0000000..1da54dc --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/PersonalRecordRepository.java @@ -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 { + + /** + * Find all personal records for a user. + */ + List findByUserIdOrderByAchievedAtDesc(UUID userId); + + /** + * Find personal records for a user filtered by activity type. + */ + List findByUserIdAndActivityTypeOrderByAchievedAtDesc( + UUID userId, + PersonalRecord.ActivityType activityType + ); + + /** + * Find a specific personal record by user, activity type, and record type. + */ + Optional 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 findRecentByUserId(@Param("userId") UUID userId, @Param("limit") int limit); +} diff --git a/src/main/java/org/operaton/fitpub/repository/TrainingLoadRepository.java b/src/main/java/org/operaton/fitpub/repository/TrainingLoadRepository.java new file mode 100644 index 0000000..2724da1 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/TrainingLoadRepository.java @@ -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 { + + /** + * Find training load for a specific user and date. + */ + Optional findByUserIdAndDate(UUID userId, LocalDate date); + + /** + * Find training load for a user within a date range. + */ + List 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 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 findByUserIdSinceDate( + @Param("userId") UUID userId, + @Param("startDate") LocalDate startDate + ); + + /** + * Get the latest training load entry for a user. + */ + Optional findFirstByUserIdOrderByDateDesc(UUID userId); +} diff --git a/src/main/java/org/operaton/fitpub/service/AchievementService.java b/src/main/java/org/operaton/fitpub/service/AchievementService.java new file mode 100644 index 0000000..7e3d817 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/AchievementService.java @@ -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 checkAndAwardAchievements(Activity activity) { + List 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 checkFirstActivityAchievements(UUID userId, Activity activity) { + List 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 checkDistanceMilestones(UUID userId) { + List 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 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 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 checkActivityCountMilestones(UUID userId) { + List achievements = new ArrayList<>(); + + long activityCount = activityRepository.countByUserId(userId); + + Map 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 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 checkStreakAchievements(UUID userId) { + List achievements = new ArrayList<>(); + + int currentStreak = calculateCurrentStreak(userId); + + Map 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 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 checkTimeBasedAchievements(UUID userId, Activity activity) { + List 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 checkElevationAchievements(UUID userId, Activity activity) { + List 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 checkVarietyAchievements(UUID userId) { + List 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 checkSpeedAchievements(UUID userId, Activity activity) { + List 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 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 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); + } +} diff --git a/src/main/java/org/operaton/fitpub/service/ActivitySummaryService.java b/src/main/java/org/operaton/fitpub/service/ActivitySummaryService.java new file mode 100644 index 0000000..66908c1 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/ActivitySummaryService.java @@ -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 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 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 getWeeklySummaries(UUID userId, int weeks) { + return activitySummaryRepository.findRecentByUserIdAndPeriodType( + userId, + ActivitySummary.PeriodType.WEEK, + weeks + ); + } + + /** + * Get monthly summaries for a user. + */ + @Transactional(readOnly = true) + public List getMonthlySummaries(UUID userId, int months) { + return activitySummaryRepository.findRecentByUserIdAndPeriodType( + userId, + ActivitySummary.PeriodType.MONTH, + months + ); + } + + /** + * Get yearly summaries for a user. + */ + @Transactional(readOnly = true) + public List 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); + } +} diff --git a/src/main/java/org/operaton/fitpub/service/FitFileService.java b/src/main/java/org/operaton/fitpub/service/FitFileService.java index 39ecc46..a71c5b5 100644 --- a/src/main/java/org/operaton/fitpub/service/FitFileService.java +++ b/src/main/java/org/operaton/fitpub/service/FitFileService.java @@ -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; } /** diff --git a/src/main/java/org/operaton/fitpub/service/OsmTileRenderer.java b/src/main/java/org/operaton/fitpub/service/OsmTileRenderer.java index 35c78ef..c4c7aa2 100644 --- a/src/main/java/org/operaton/fitpub/service/OsmTileRenderer.java +++ b/src/main/java/org/operaton/fitpub/service/OsmTileRenderer.java @@ -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; diff --git a/src/main/java/org/operaton/fitpub/service/PersonalRecordService.java b/src/main/java/org/operaton/fitpub/service/PersonalRecordService.java new file mode 100644 index 0000000..dbbdbfc --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/PersonalRecordService.java @@ -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 checkAndUpdatePersonalRecords(Activity activity) { + List 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 checkDistancePRs(Activity activity) { + List 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 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 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 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 getPersonalRecords(UUID userId) { + return personalRecordRepository.findByUserIdOrderByAchievedAtDesc(userId); + } + + /** + * Get personal records for a user filtered by activity type. + */ + @Transactional(readOnly = true) + public List 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); + } +} diff --git a/src/main/java/org/operaton/fitpub/service/TrainingLoadService.java b/src/main/java/org/operaton/fitpub/service/TrainingLoadService.java new file mode 100644 index 0000000..d9554b7 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/TrainingLoadService.java @@ -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 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 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 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 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 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); + } +} diff --git a/src/main/resources/db/migration/V10__create_analytics_tables.sql b/src/main/resources/db/migration/V10__create_analytics_tables.sql new file mode 100644 index 0000000..ccb2e3d --- /dev/null +++ b/src/main/resources/db/migration/V10__create_analytics_tables.sql @@ -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'; diff --git a/src/main/resources/static/js/auth.js b/src/main/resources/static/js/auth.js index e2be6e3..69cc059 100644 --- a/src/main/resources/static/js/auth.js +++ b/src/main/resources/static/js/auth.js @@ -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'; + } } }, diff --git a/src/main/resources/templates/analytics/achievements.html b/src/main/resources/templates/analytics/achievements.html new file mode 100644 index 0000000..1bccd3b --- /dev/null +++ b/src/main/resources/templates/analytics/achievements.html @@ -0,0 +1,267 @@ + + + + Achievements - FitPub + + +
+
+
+

+ Achievements +

+ + Back to Dashboard + +
+ + +
+
+
+
+

0

+

Achievements Earned

+
+
+
+
+
+
+

-

+

Latest Achievement

+
+
+
+
+
+
+

0%

+

Collection Progress

+
+
+
+
+ + +
+
+ Loading... +
+

Loading your achievements...

+
+ + + + + + +
+ + + + +
+ + diff --git a/src/main/resources/templates/analytics/dashboard.html b/src/main/resources/templates/analytics/dashboard.html new file mode 100644 index 0000000..f495962 --- /dev/null +++ b/src/main/resources/templates/analytics/dashboard.html @@ -0,0 +1,349 @@ + + + + Analytics Dashboard - FitPub + + +
+
+

+ Analytics Dashboard +

+ + +
+
+ Loading... +
+

Loading your analytics...

+
+ + + + + + +
+ + + + +
+ + diff --git a/src/main/resources/templates/analytics/personal-records.html b/src/main/resources/templates/analytics/personal-records.html new file mode 100644 index 0000000..573d737 --- /dev/null +++ b/src/main/resources/templates/analytics/personal-records.html @@ -0,0 +1,277 @@ + + + + Personal Records - FitPub + + +
+
+
+

+ Personal Records +

+ + Back to Dashboard + +
+ + +
+
+ Loading... +
+

Loading your personal records...

+
+ + + + + + +
+ + + + +
+ + diff --git a/src/main/resources/templates/analytics/summaries.html b/src/main/resources/templates/analytics/summaries.html new file mode 100644 index 0000000..1d27985 --- /dev/null +++ b/src/main/resources/templates/analytics/summaries.html @@ -0,0 +1,264 @@ + + + + Activity Summaries - FitPub + + +
+
+
+

+ Activity Summaries +

+ + Back to Dashboard + +
+ + + + + +
+
+ Loading... +
+

Loading summaries...

+
+ + + + + + +
+ + + + +
+ + diff --git a/src/main/resources/templates/analytics/training-load.html b/src/main/resources/templates/analytics/training-load.html new file mode 100644 index 0000000..19187b0 --- /dev/null +++ b/src/main/resources/templates/analytics/training-load.html @@ -0,0 +1,291 @@ + + + + Training Load - FitPub + + +
+
+
+

+ Training Load +

+ + Back to Dashboard + +
+ + +
+
+

Current Form Status

+

-

+

+
+
+ + +
+ + + +
+ + +
+
+ Loading... +
+

Loading training load data...

+
+ + + + + + +
+ + + +
+ + diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html index 77df72c..b025177 100644 --- a/src/main/resources/templates/layout.html +++ b/src/main/resources/templates/layout.html @@ -70,6 +70,11 @@ Upload +