DailyChallengeService.java
package com.wavii.service;
import com.wavii.dto.challenge.CompleteChallengResponseDto;
import com.wavii.dto.challenge.DailyChallengeDto;
import com.wavii.dto.challenge.StatsDto;
import com.wavii.model.DailyChallenge;
import com.wavii.model.PdfDocument;
import com.wavii.model.User;
import com.wavii.model.UserChallengeCompletion;
import com.wavii.model.enums.Level;
import com.wavii.repository.DailyChallengeRepository;
import com.wavii.repository.PdfDocumentRepository;
import com.wavii.repository.UserChallengeCompletionRepository;
import com.wavii.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Servicio para la gestión de desafíos diarios y progreso del usuario.
* Controla la generación de retos, cálculo de XP, niveles y rachas de actividad.
*
* @author eduglezexp
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DailyChallengeService {
private static final int CHALLENGES_PER_LEVEL = 4;
// XP por dificultad
private static final int XP_PRINCIPIANTE = 15;
private static final int XP_INTERMEDIO = 30;
private static final int XP_AVANZADO = 50;
// XP necesario para pasar del nivel N al N+1: 100 * N^1.5
// El nivel numerico interno va de 1 en adelante (no confundir con Level enum del onboarding)
private final DailyChallengeRepository challengeRepo;
private final PdfDocumentRepository pdfDocumentRepo;
private final UserChallengeCompletionRepository completionRepo;
private final UserRepository userRepo;
// -------------------------------------------------------------------------
// Generacion automatica de desafios del dia
// -------------------------------------------------------------------------
/**
* Genera los desafíos del día si aún no existen para el día actual.
* Se invoca automáticamente al solicitar los desafíos de hoy.
*/
@Transactional
public void generateTodayChallengesIfNeeded() {
LocalDate today = LocalDate.now();
LocalDate sevenDaysAgo = today.minusDays(7);
for (Level level : Level.values()) {
generateChallengesForLevelIfNeeded(today, level, sevenDaysAgo);
}
}
/**
* Devuelve los desafíos de hoy visibles para el usuario según su nivel de dificultad.
*
* @param user Usuario que solicita los desafíos.
* @return Lista de desafíos diarios con su estado de completitud.
*/
@Transactional
public List<DailyChallengeDto> getTodayChallengesForUser(User user) {
generateTodayChallengesIfNeeded();
LocalDate today = LocalDate.now();
Level effectiveLevel = user.getLevel() != null ? user.getLevel() : Level.PRINCIPIANTE;
List<DailyChallenge> all = challengeRepo.findByChallengeDateAndDifficultyOrderBySlotAsc(today, effectiveLevel);
List<DailyChallengeDto> result = new ArrayList<>();
for (DailyChallenge challenge : all) {
boolean completed = completionRepo.existsByUserAndDailyChallenge(user, challenge);
result.add(DailyChallengeDto.from(challenge, completed));
}
return result;
}
/**
* Marca un desafío como completado para el usuario actual, otorgando XP y actualizando rachas.
*
* @param challengeId ID del desafío a completar.
* @param user Usuario que completa el desafío.
* @return Respuesta con XP ganado, nuevo nivel y estado de racha.
*/
@Transactional
public CompleteChallengResponseDto completeChallenge(Long challengeId, User user) {
DailyChallenge challenge = challengeRepo.findById(challengeId)
.orElseThrow(() -> new IllegalArgumentException("Desafio no encontrado"));
// Solo se puede completar el desafio del dia
if (!challenge.getChallengeDate().equals(LocalDate.now())) {
throw new IllegalStateException("Este desafio ya no esta disponible");
}
// No se puede completar dos veces
if (completionRepo.existsByUserAndDailyChallenge(user, challenge)) {
throw new IllegalStateException("Ya has completado este desafio hoy");
}
// El usuario debe tener acceso a esa dificultad
if (!isVisibleForUser(challenge.getDifficulty(), user.getLevel())) {
throw new IllegalStateException("No tienes acceso a este nivel de desafio");
}
// Registrar la completion
UserChallengeCompletion completion = UserChallengeCompletion.builder()
.user(user)
.dailyChallenge(challenge)
.completedDate(LocalDate.now())
.build();
completionRepo.save(completion);
// Actualizar XP
int xpGained = challenge.getXpReward();
int oldXp = user.getXp();
int newXp = oldXp + xpGained;
int oldLevel = calculateLevel(oldXp);
int newLevel = calculateLevel(newXp);
boolean leveledUp = newLevel > oldLevel;
user.setXp(newXp);
// Actualizar racha
updateStreak(user);
userRepo.save(user);
log.info("Usuario {} completo desafio {}. XP: {} -> {}, Racha: {}",
user.getId(), challengeId, oldXp, newXp, user.getStreak());
return new CompleteChallengResponseDto(xpGained, newXp, newLevel, leveledUp,
user.getStreak(), user.getBestStreak());
}
/**
* Obtiene las estadísticas de progreso del usuario (XP, nivel, racha y calendario mensual).
*
* @param user Usuario actual.
* @return DTO con estadísticas y fechas de actividad.
*/
@Transactional(readOnly = true)
public StatsDto getStats(User user) {
LocalDate today = LocalDate.now();
LocalDate monthStart = today.with(TemporalAdjusters.firstDayOfMonth());
LocalDate monthEnd = today.with(TemporalAdjusters.lastDayOfMonth());
List<LocalDate> completedThisMonth = completionRepo.findCompletedDatesByUserInRange(
user.getId(), monthStart, monthEnd);
// Dias completados en la semana actual (lunes a hoy)
LocalDate weekStart = today.with(java.time.DayOfWeek.MONDAY);
List<LocalDate> completedThisWeek = completionRepo.findCompletedDatesByUserInRange(
user.getId(), weekStart, today);
return new StatsDto(
user.getStreak(),
user.getBestStreak(),
user.getXp(),
calculateLevel(user.getXp()),
completedThisMonth,
completedThisWeek.size()
);
}
// -------------------------------------------------------------------------
// Logica privada
// -------------------------------------------------------------------------
/**
* Calcula el nivel numerico del usuario a partir de su XP total.
* Formula: nivel N requiere 100 * N^1.5 XP acumulado desde el nivel anterior.
* Se itera hasta que el XP acumulado supere el XP del usuario.
*/
public static int calculateLevel(int xp) {
int level = 1;
int accumulated = 0;
while (true) {
int needed = (int) Math.round(100 * Math.pow(level, 1.5));
if (accumulated + needed > xp) break;
accumulated += needed;
level++;
}
return level;
}
/**
* Calcula el XP total necesario para alcanzar el nivel indicado (XP acumulado desde nivel 1).
*/
public static int xpForLevel(int targetLevel) {
int total = 0;
for (int l = 1; l < targetLevel; l++) {
total += (int) Math.round(100 * Math.pow(l, 1.5));
}
return total;
}
private static int xpForLevel(Level level) {
return switch (level) {
case PRINCIPIANTE -> XP_PRINCIPIANTE;
case INTERMEDIO -> XP_INTERMEDIO;
case AVANZADO -> XP_AVANZADO;
};
}
private static int levelToInt(Level level) {
return switch (level) {
case PRINCIPIANTE -> 1;
case INTERMEDIO -> 2;
case AVANZADO -> 3;
};
}
/**
* Un usuario ve solo los desafios de su nivel actual.
*/
private boolean isVisibleForUser(Level challengeLevel, Level userLevel) {
if (userLevel == null) return challengeLevel == Level.PRINCIPIANTE;
return challengeLevel == userLevel;
}
/**
* Actualiza la racha del usuario:
* - Si lastStreakDate es ayer: incrementa racha.
* - Si lastStreakDate es hoy: ya conto, no hace nada.
* - Cualquier otro caso (null, antes de ayer): reinicia a 1.
* Actualiza bestStreak si se supera el record.
*/
private void updateStreak(User user) {
LocalDate today = LocalDate.now();
LocalDate last = user.getLastStreakDate();
if (today.equals(last)) {
// Ya se conto hoy, no cambia nada
return;
}
if (last != null && last.equals(today.minusDays(1))) {
// Dia consecutivo
user.setStreak(user.getStreak() + 1);
} else {
// Fallo uno o mas dias, o primer desafio de siempre
user.setStreak(1);
}
user.setLastStreakDate(today);
if (user.getStreak() > user.getBestStreak()) {
user.setBestStreak(user.getStreak());
}
}
private void generateChallengesForLevelIfNeeded(LocalDate today, Level level, LocalDate sevenDaysAgo) {
List<DailyChallenge> existing = challengeRepo.findByChallengeDateAndDifficultyOrderBySlotAsc(today, level);
if (existing.size() >= CHALLENGES_PER_LEVEL) {
return;
}
int difficultyInt = levelToInt(level);
Set<Long> usedPdfIds = new HashSet<>();
existing.forEach(challenge -> usedPdfIds.add(challenge.getPdfDocument().getId()));
challengeRepo.findByChallengeDateGreaterThanEqual(sevenDaysAgo)
.forEach(challenge -> usedPdfIds.add(challenge.getPdfDocument().getId()));
List<PdfDocument> selectedTabs = pickTabsForDifficulty(difficultyInt, usedPdfIds, CHALLENGES_PER_LEVEL - existing.size());
int nextSlot = existing.size() + 1;
for (PdfDocument tab : selectedTabs) {
if (nextSlot > CHALLENGES_PER_LEVEL) {
break;
}
DailyChallenge challenge = DailyChallenge.builder()
.challengeDate(today)
.difficulty(level)
.slot(nextSlot)
.xpReward(xpForLevel(level))
.pdfDocument(tab)
.build();
challengeRepo.save(challenge);
log.info("Desafio generado para {} en slot {} con tablatura {}", level, nextSlot, tab.getId());
nextSlot++;
}
if (existing.size() + selectedTabs.size() < CHALLENGES_PER_LEVEL) {
log.warn("Solo se pudieron generar {} desafios para nivel {} en {}",
existing.size() + selectedTabs.size(), level, today);
}
}
private void collectTabs(List<PdfDocument> selectedTabs, Set<Long> usedPdfIds, List<PdfDocument> candidates, int targetSize) {
for (PdfDocument candidate : candidates) {
if (usedPdfIds.add(candidate.getId())) {
selectedTabs.add(candidate);
}
if (selectedTabs.size() >= targetSize) {
return;
}
}
}
private List<PdfDocument> pickTabsForDifficulty(int difficultyInt, Set<Long> recentlyUsedPdfIds, int needed) {
if (needed <= 0) {
return List.of();
}
List<PdfDocument> allCandidates = new ArrayList<>(pdfDocumentRepo.findAllByDifficultyWithOwner(difficultyInt));
Collections.shuffle(allCandidates);
List<PdfDocument> freshCandidates = new ArrayList<>();
List<PdfDocument> fallbackCandidates = new ArrayList<>();
for (PdfDocument candidate : allCandidates) {
if (recentlyUsedPdfIds.contains(candidate.getId())) {
fallbackCandidates.add(candidate);
} else {
freshCandidates.add(candidate);
}
}
List<PdfDocument> selectedTabs = new ArrayList<>();
Set<Long> selectedIds = new HashSet<>();
collectTabs(selectedTabs, selectedIds, freshCandidates, needed);
if (selectedTabs.size() < needed) {
collectTabs(selectedTabs, selectedIds, fallbackCandidates, needed);
}
return selectedTabs;
}
}