diff --git a/backend/src/main/java/com/fitnessapp/backend/FitnessAppApplication.java b/backend/src/main/java/com/fitnessapp/backend/FitnessAppApplication.java index 103af74..2b8cf31 100644 --- a/backend/src/main/java/com/fitnessapp/backend/FitnessAppApplication.java +++ b/backend/src/main/java/com/fitnessapp/backend/FitnessAppApplication.java @@ -7,6 +7,7 @@ import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @ConfigurationPropertiesScan(basePackages = { @@ -21,6 +22,7 @@ "com.aura" }) @EnableCaching +@EnableScheduling @EnableJpaRepositories(basePackages = { "com.fitnessapp.backend.user.repository", "com.fitnessapp.backend.recipe.repository", @@ -31,6 +33,7 @@ "com.fitnessapp.backend.repository", "com.fitnessapp.backend.goals.repository", "com.fitnessapp.backend.weight.repository", + "com.fitnessapp.backend.squad.repository", "com.aura.repository" }) public class FitnessAppApplication { diff --git a/backend/src/main/java/com/fitnessapp/backend/api/common/ErrorCode.java b/backend/src/main/java/com/fitnessapp/backend/api/common/ErrorCode.java index db5cb45..bb6fc0a 100644 --- a/backend/src/main/java/com/fitnessapp/backend/api/common/ErrorCode.java +++ b/backend/src/main/java/com/fitnessapp/backend/api/common/ErrorCode.java @@ -30,6 +30,16 @@ public enum ErrorCode { MEAL_NOT_FOUND(2002, "Meal not found", HttpStatus.NOT_FOUND), PROFILE_NOT_FOUND(2003, "User profile not found", HttpStatus.NOT_FOUND), + // Squad errors (2010-2019) — see com.fitnessapp.backend.squad + SQUAD_NOT_FOUND(2010, "Squad not found", HttpStatus.NOT_FOUND), + SQUAD_FULL(2011, "Squad has reached the 10-member limit", HttpStatus.CONFLICT), + SQUAD_LIMIT_REACHED(2012, "You are already in the maximum number of squads", HttpStatus.CONFLICT), + SQUAD_INVITE_CODE_INVALID(2013, "Invite code is invalid or expired", HttpStatus.NOT_FOUND), + SQUAD_ACCESS_DENIED(2014, "You are not a member of this squad", HttpStatus.FORBIDDEN), + SQUAD_ALREADY_MEMBER(2015, "You are already a member of this squad", HttpStatus.CONFLICT), + KUDOS_FORBIDDEN(2016, "You cannot give kudos to this meal", HttpStatus.FORBIDDEN), + KUDOS_SELF_FORBIDDEN(2017, "You cannot give kudos to your own meal", HttpStatus.BAD_REQUEST), + // AI/Vision errors (3xxx) AI_SERVICE_UNAVAILABLE(3001, "AI service temporarily unavailable", HttpStatus.SERVICE_UNAVAILABLE), AI_RECOGNITION_FAILED(3002, "Food recognition failed", HttpStatus.BAD_REQUEST), diff --git a/backend/src/main/java/com/fitnessapp/backend/api/common/GlobalExceptionHandler.java b/backend/src/main/java/com/fitnessapp/backend/api/common/GlobalExceptionHandler.java index 96f47a1..f6da7f2 100644 --- a/backend/src/main/java/com/fitnessapp/backend/api/common/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/fitnessapp/backend/api/common/GlobalExceptionHandler.java @@ -23,6 +23,7 @@ import com.fitnessapp.backend.auth.AuthenticationException; import com.fitnessapp.backend.nutrition.exception.FoodRecognitionException; import com.fitnessapp.backend.recommendation.exception.RecommendationException; +import com.fitnessapp.backend.squad.SquadException; import jakarta.persistence.EntityNotFoundException; import jakarta.servlet.http.HttpServletRequest; @@ -75,6 +76,20 @@ public ResponseEntity> handleEmbeddingException( return ResponseEntity.status(errorCode.getHttpStatus()).body(response); } + // ========== Squad Exceptions ========== + + @ExceptionHandler(SquadException.class) + public ResponseEntity> handleSquadException( + SquadException ex, + HttpServletRequest request + ) { + ErrorCode errorCode = ex.getErrorCode(); + log.warn("Squad error [{}]: {}", errorCode.getCode(), ex.getMessage()); + + ApiEnvelope response = ApiEnvelope.error(errorCode, ex.getMessage(), request.getRequestURI()); + return ResponseEntity.status(errorCode.getHttpStatus()).body(response); + } + // ========== Entity Exceptions ========== @ExceptionHandler(EntityNotFoundException.class) diff --git a/backend/src/main/java/com/fitnessapp/backend/nutrition/repository/MealLogRepository.java b/backend/src/main/java/com/fitnessapp/backend/nutrition/repository/MealLogRepository.java index a12c9d1..6c941f4 100644 --- a/backend/src/main/java/com/fitnessapp/backend/nutrition/repository/MealLogRepository.java +++ b/backend/src/main/java/com/fitnessapp/backend/nutrition/repository/MealLogRepository.java @@ -138,6 +138,24 @@ interface MealLogLeaderboardRow { OffsetDateTime getLastLog(); } + // ========== Squads / leaderboard helpers ========== + + long countByUserIdAndConsumedAtAfter(UUID userId, OffsetDateTime since); + + @Query("SELECT COUNT(DISTINCT FUNCTION('DATE', m.consumedAt)) FROM MealLog m WHERE m.userId = :userId AND m.consumedAt >= :since") + long countDistinctDaysByUserSince(@Param("userId") UUID userId, @Param("since") OffsetDateTime since); + + /** Returns true if at least one meal log exists for {@code userId} in the half-open window. */ + @Query(""" + SELECT COUNT(m) > 0 FROM MealLog m + WHERE m.userId = :userId + AND m.consumedAt >= :start + AND m.consumedAt < :end + """) + boolean existsLogInRange(@Param("userId") UUID userId, + @Param("start") OffsetDateTime start, + @Param("end") OffsetDateTime end); + interface DailyNutritionSummary { LocalDate getDate(); Long getMealCount(); diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/SquadException.java b/backend/src/main/java/com/fitnessapp/backend/squad/SquadException.java new file mode 100644 index 0000000..5d0b1f8 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/SquadException.java @@ -0,0 +1,25 @@ +package com.fitnessapp.backend.squad; + +import com.fitnessapp.backend.api.common.ErrorCode; +import lombok.Getter; + +/** + * Domain exception for the Squads feature. Carries an {@link ErrorCode} so + * {@code GlobalExceptionHandler} can map it to a consistent {@code ApiEnvelope} + * response with the correct HTTP status. + */ +@Getter +public class SquadException extends RuntimeException { + + private final ErrorCode errorCode; + + public SquadException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public SquadException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/controller/KudosController.java b/backend/src/main/java/com/fitnessapp/backend/squad/controller/KudosController.java new file mode 100644 index 0000000..e053f2f --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/controller/KudosController.java @@ -0,0 +1,26 @@ +package com.fitnessapp.backend.squad.controller; + +import com.fitnessapp.backend.security.CurrentUser; +import com.fitnessapp.backend.squad.dto.KudosResponse; +import com.fitnessapp.backend.squad.service.KudosService; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/meal-logs/{mealLogId}/kudos") +@RequiredArgsConstructor +public class KudosController { + + private final CurrentUser currentUser; + private final KudosService kudosService; + + /** Toggle kudos. Body-less — server flips the existing state. */ + @PostMapping + public ResponseEntity toggle(@PathVariable Long mealLogId) { + UUID userId = currentUser.requireUserId(); + return ResponseEntity.ok(kudosService.toggle(userId, mealLogId)); + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/controller/SquadController.java b/backend/src/main/java/com/fitnessapp/backend/squad/controller/SquadController.java new file mode 100644 index 0000000..cbeb519 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/controller/SquadController.java @@ -0,0 +1,73 @@ +package com.fitnessapp.backend.squad.controller; + +import com.fitnessapp.backend.security.CurrentUser; +import com.fitnessapp.backend.squad.dto.CreateSquadRequest; +import com.fitnessapp.backend.squad.dto.JoinSquadRequest; +import com.fitnessapp.backend.squad.dto.LeaderboardEntry; +import com.fitnessapp.backend.squad.dto.SquadDetailResponse; +import com.fitnessapp.backend.squad.dto.SquadResponse; +import com.fitnessapp.backend.squad.service.SquadService; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/squads") +@Validated +@RequiredArgsConstructor +public class SquadController { + + private final CurrentUser currentUser; + private final SquadService squadService; + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateSquadRequest request) { + UUID userId = currentUser.requireUserId(); + SquadResponse response = squadService.create(userId, request.name(), request.emoji(), request.timezone()); + return ResponseEntity.ok(response); + } + + @PostMapping("/join") + public ResponseEntity join(@Valid @RequestBody JoinSquadRequest request) { + UUID userId = currentUser.requireUserId(); + SquadResponse response = squadService.joinByCode(userId, request.inviteCode()); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> list() { + UUID userId = currentUser.requireUserId(); + return ResponseEntity.ok(squadService.listForUser(userId)); + } + + @GetMapping("/{squadId}") + public ResponseEntity detail(@PathVariable UUID squadId) { + UUID userId = currentUser.requireUserId(); + return ResponseEntity.ok(squadService.getDetail(userId, squadId)); + } + + @PostMapping("/{squadId}/leave") + public ResponseEntity leave(@PathVariable UUID squadId) { + UUID userId = currentUser.requireUserId(); + squadService.leave(userId, squadId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{squadId}/members/{targetUserId}") + public ResponseEntity removeMember(@PathVariable UUID squadId, @PathVariable UUID targetUserId) { + UUID userId = currentUser.requireUserId(); + squadService.removeMember(userId, squadId, targetUserId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{squadId}/leaderboard") + public ResponseEntity> leaderboard(@PathVariable UUID squadId) { + UUID userId = currentUser.requireUserId(); + return ResponseEntity.ok(squadService.leaderboard(userId, squadId)); + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/CreateSquadRequest.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/CreateSquadRequest.java new file mode 100644 index 0000000..e163092 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/CreateSquadRequest.java @@ -0,0 +1,10 @@ +package com.fitnessapp.backend.squad.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateSquadRequest( + @NotBlank @Size(max = 30) String name, + @NotBlank @Size(max = 8) String emoji, + @Size(max = 64) String timezone +) {} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/JoinSquadRequest.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/JoinSquadRequest.java new file mode 100644 index 0000000..96ee74d --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/JoinSquadRequest.java @@ -0,0 +1,8 @@ +package com.fitnessapp.backend.squad.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record JoinSquadRequest( + @NotBlank @Size(min = 6, max = 6) String inviteCode +) {} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/KudosResponse.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/KudosResponse.java new file mode 100644 index 0000000..df68198 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/KudosResponse.java @@ -0,0 +1,7 @@ +package com.fitnessapp.backend.squad.dto; + +public record KudosResponse( + long mealLogId, + long kudosCount, + boolean kudoed +) {} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/LeaderboardEntry.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/LeaderboardEntry.java new file mode 100644 index 0000000..4a72d8e --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/LeaderboardEntry.java @@ -0,0 +1,11 @@ +package com.fitnessapp.backend.squad.dto; + +import java.util.UUID; + +public record LeaderboardEntry( + UUID userId, + int rank, + long mealsLogged, + long daysActive, + boolean warmingUp +) {} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadDetailResponse.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadDetailResponse.java new file mode 100644 index 0000000..6446631 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadDetailResponse.java @@ -0,0 +1,8 @@ +package com.fitnessapp.backend.squad.dto; + +import java.util.List; + +public record SquadDetailResponse( + SquadResponse squad, + List members +) {} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMapper.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMapper.java new file mode 100644 index 0000000..a68c321 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMapper.java @@ -0,0 +1,34 @@ +package com.fitnessapp.backend.squad.dto; + +import com.fitnessapp.backend.squad.entity.Squad; +import com.fitnessapp.backend.squad.entity.SquadMember; +import java.util.List; + +public final class SquadMapper { + + private SquadMapper() {} + + public static SquadResponse toResponse(Squad s, long memberCount) { + return new SquadResponse( + s.getId(), + s.getName(), + s.getEmoji(), + s.getInviteCode(), + s.getOwnerUserId(), + (int) memberCount, + s.getCurrentStreak() == null ? 0 : s.getCurrentStreak(), + s.getLongestStreak() == null ? 0 : s.getLongestStreak(), + s.getLastActiveDay(), + s.getTimezone(), + s.getCreatedAt() + ); + } + + public static SquadMemberSummary toMemberSummary(SquadMember m) { + return new SquadMemberSummary(m.getUserId(), m.getRole(), m.getJoinedAt()); + } + + public static List toMemberSummaries(List members) { + return members.stream().map(SquadMapper::toMemberSummary).toList(); + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMemberSummary.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMemberSummary.java new file mode 100644 index 0000000..d3c0264 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMemberSummary.java @@ -0,0 +1,10 @@ +package com.fitnessapp.backend.squad.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public record SquadMemberSummary( + UUID userId, + String role, + OffsetDateTime joinedAt +) {} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadResponse.java b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadResponse.java new file mode 100644 index 0000000..671fb09 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadResponse.java @@ -0,0 +1,19 @@ +package com.fitnessapp.backend.squad.dto; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record SquadResponse( + UUID id, + String name, + String emoji, + String inviteCode, + UUID ownerUserId, + int memberCount, + int currentStreak, + int longestStreak, + LocalDate lastActiveDay, + String timezone, + OffsetDateTime createdAt +) {} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudos.java b/backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudos.java new file mode 100644 index 0000000..d69e77d --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudos.java @@ -0,0 +1,28 @@ +package com.fitnessapp.backend.squad.entity; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.*; + +@Entity +@Table(name = "meal_log_kudos") +@IdClass(MealLogKudosId.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MealLogKudos { + + @Id + @Column(name = "meal_log_id", nullable = false) + private Long mealLogId; + + @Id + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userId; + + @Column(name = "created_at", insertable = false, updatable = false) + private OffsetDateTime createdAt; +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudosId.java b/backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudosId.java new file mode 100644 index 0000000..8df4af5 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudosId.java @@ -0,0 +1,26 @@ +package com.fitnessapp.backend.squad.entity; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +public class MealLogKudosId implements Serializable { + private Long mealLogId; + private UUID userId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MealLogKudosId that)) return false; + return Objects.equals(mealLogId, that.mealLogId) && Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hash(mealLogId, userId); + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/entity/Squad.java b/backend/src/main/java/com/fitnessapp/backend/squad/entity/Squad.java new file mode 100644 index 0000000..9a19e67 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/entity/Squad.java @@ -0,0 +1,52 @@ +package com.fitnessapp.backend.squad.entity; + +import jakarta.persistence.*; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.*; + +@Entity +@Table(name = "squads") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Squad { + + @Id + @Column(columnDefinition = "uuid") + @GeneratedValue + private UUID id; + + @Column(nullable = false, length = 30) + private String name; + + @Column(nullable = false, length = 8) + private String emoji; + + @Column(name = "invite_code", nullable = false, unique = true, length = 6) + private String inviteCode; + + @Column(name = "owner_user_id", nullable = false, columnDefinition = "uuid") + private UUID ownerUserId; + + @Column(name = "current_streak", nullable = false) + @Builder.Default + private Integer currentStreak = 0; + + @Column(name = "longest_streak", nullable = false) + @Builder.Default + private Integer longestStreak = 0; + + @Column(name = "last_active_day") + private LocalDate lastActiveDay; + + @Column(nullable = false, length = 64) + @Builder.Default + private String timezone = "UTC"; + + @Column(name = "created_at", insertable = false, updatable = false) + private OffsetDateTime createdAt; +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMember.java b/backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMember.java new file mode 100644 index 0000000..19aa8d9 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMember.java @@ -0,0 +1,32 @@ +package com.fitnessapp.backend.squad.entity; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.*; + +@Entity +@Table(name = "squad_members") +@IdClass(SquadMemberId.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SquadMember { + + @Id + @Column(name = "squad_id", nullable = false, columnDefinition = "uuid") + private UUID squadId; + + @Id + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userId; + + @Column(nullable = false, length = 16) + @Builder.Default + private String role = "member"; + + @Column(name = "joined_at", insertable = false, updatable = false) + private OffsetDateTime joinedAt; +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMemberId.java b/backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMemberId.java new file mode 100644 index 0000000..603b1ca --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMemberId.java @@ -0,0 +1,26 @@ +package com.fitnessapp.backend.squad.entity; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +public class SquadMemberId implements Serializable { + private UUID squadId; + private UUID userId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SquadMemberId that)) return false; + return Objects.equals(squadId, that.squadId) && Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hash(squadId, userId); + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/repository/MealLogKudosRepository.java b/backend/src/main/java/com/fitnessapp/backend/squad/repository/MealLogKudosRepository.java new file mode 100644 index 0000000..b4c68d8 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/repository/MealLogKudosRepository.java @@ -0,0 +1,15 @@ +package com.fitnessapp.backend.squad.repository; + +import com.fitnessapp.backend.squad.entity.MealLogKudos; +import com.fitnessapp.backend.squad.entity.MealLogKudosId; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MealLogKudosRepository extends JpaRepository { + + long countByMealLogId(Long mealLogId); + + boolean existsByMealLogIdAndUserId(Long mealLogId, UUID userId); + + void deleteByMealLogIdAndUserId(Long mealLogId, UUID userId); +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadMemberRepository.java b/backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadMemberRepository.java new file mode 100644 index 0000000..fe721f0 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadMemberRepository.java @@ -0,0 +1,22 @@ +package com.fitnessapp.backend.squad.repository; + +import com.fitnessapp.backend.squad.entity.SquadMember; +import com.fitnessapp.backend.squad.entity.SquadMemberId; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SquadMemberRepository extends JpaRepository { + + List findAllBySquadId(UUID squadId); + + List findAllByUserId(UUID userId); + + long countBySquadId(UUID squadId); + + long countByUserId(UUID userId); + + boolean existsBySquadIdAndUserId(UUID squadId, UUID userId); + + void deleteBySquadIdAndUserId(UUID squadId, UUID userId); +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadRepository.java b/backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadRepository.java new file mode 100644 index 0000000..55c89ab --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadRepository.java @@ -0,0 +1,31 @@ +package com.fitnessapp.backend.squad.repository; + +import com.fitnessapp.backend.squad.entity.Squad; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SquadRepository extends JpaRepository { + + Optional findByInviteCode(String inviteCode); + + boolean existsByInviteCode(String inviteCode); + + @Query(""" + SELECT s FROM Squad s + WHERE s.id IN ( + SELECT m.squadId FROM SquadMember m WHERE m.userId = :userId + ) + ORDER BY s.createdAt DESC NULLS LAST + """) + List findAllForMember(@Param("userId") UUID userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Squad s WHERE s.id = :id") + Optional findByIdForUpdate(@Param("id") UUID id); +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/service/InviteCodeGenerator.java b/backend/src/main/java/com/fitnessapp/backend/squad/service/InviteCodeGenerator.java new file mode 100644 index 0000000..2086097 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/service/InviteCodeGenerator.java @@ -0,0 +1,41 @@ +package com.fitnessapp.backend.squad.service; + +import com.fitnessapp.backend.squad.repository.SquadRepository; +import java.security.SecureRandom; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Generates 6-character squad invite codes. Uses an unambiguous 32-char alphabet + * (no 0/O/1/I) so codes are easy to read aloud. Collision rate at 32^6 (~1.07B) + * is negligible for our scale; we still retry up to {@link #MAX_ATTEMPTS}. + */ +@Component +@RequiredArgsConstructor +public class InviteCodeGenerator { + + static final String ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + static final int CODE_LENGTH = 6; + static final int MAX_ATTEMPTS = 8; + + private final SquadRepository squadRepository; + private final SecureRandom random = new SecureRandom(); + + public String generateUnique() { + for (int i = 0; i < MAX_ATTEMPTS; i++) { + String code = generate(); + if (!squadRepository.existsByInviteCode(code)) { + return code; + } + } + throw new IllegalStateException("Could not generate a unique invite code after " + MAX_ATTEMPTS + " attempts"); + } + + String generate() { + char[] buf = new char[CODE_LENGTH]; + for (int i = 0; i < CODE_LENGTH; i++) { + buf[i] = ALPHABET.charAt(random.nextInt(ALPHABET.length())); + } + return new String(buf); + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/service/KudosService.java b/backend/src/main/java/com/fitnessapp/backend/squad/service/KudosService.java new file mode 100644 index 0000000..b0b3400 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/service/KudosService.java @@ -0,0 +1,77 @@ +package com.fitnessapp.backend.squad.service; + +import com.fitnessapp.backend.api.common.ErrorCode; +import com.fitnessapp.backend.nutrition.entity.MealLog; +import com.fitnessapp.backend.nutrition.repository.MealLogRepository; +import com.fitnessapp.backend.squad.SquadException; +import com.fitnessapp.backend.squad.dto.KudosResponse; +import com.fitnessapp.backend.squad.entity.MealLogKudos; +import com.fitnessapp.backend.squad.repository.MealLogKudosRepository; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Toggle Kudos on a meal log. Kudos is allowed only when: + *
    + *
  • The actor is not the meal owner.
  • + *
  • The meal owner shares at least one squad with the actor.
  • + *
  • The meal was consumed within the last 7 days.
  • + *
+ */ +@Service +@Slf4j +@RequiredArgsConstructor +public class KudosService { + + static final int KUDOS_WINDOW_DAYS = 7; + + private final MealLogRepository mealLogRepository; + private final MealLogKudosRepository kudosRepository; + private final SquadService squadService; + + /** + * Toggle a kudos. If the user has already kudoed this meal the row is removed; + * otherwise it is created. Returns the post-toggle count and the new state. + */ + @Transactional + public KudosResponse toggle(UUID actorUserId, Long mealLogId) { + MealLog meal = mealLogRepository.findById(mealLogId) + .orElseThrow(() -> new SquadException(ErrorCode.MEAL_NOT_FOUND)); + + if (meal.getUserId().equals(actorUserId)) { + throw new SquadException(ErrorCode.KUDOS_SELF_FORBIDDEN); + } + + OffsetDateTime cutoff = OffsetDateTime.now(ZoneOffset.UTC).minusDays(KUDOS_WINDOW_DAYS); + if (meal.getConsumedAt() == null || meal.getConsumedAt().isBefore(cutoff)) { + throw new SquadException(ErrorCode.KUDOS_FORBIDDEN, "Kudos window has expired for this meal"); + } + + if (!squadService.shareSquad(actorUserId, meal.getUserId())) { + throw new SquadException(ErrorCode.KUDOS_FORBIDDEN, "Kudos requires sharing a squad"); + } + + boolean kudoed; + if (kudosRepository.existsByMealLogIdAndUserId(mealLogId, actorUserId)) { + kudosRepository.deleteByMealLogIdAndUserId(mealLogId, actorUserId); + kudoed = false; + } else { + kudosRepository.save(MealLogKudos.builder() + .mealLogId(mealLogId) + .userId(actorUserId) + .build()); + kudoed = true; + } + + long count = kudosRepository.countByMealLogId(mealLogId); + log.info("Kudos toggle: actor={} meal={} kudoed={} count={}", actorUserId, mealLogId, kudoed, count); + return new KudosResponse(mealLogId, count, kudoed); + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/service/SquadService.java b/backend/src/main/java/com/fitnessapp/backend/squad/service/SquadService.java new file mode 100644 index 0000000..346916c --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/service/SquadService.java @@ -0,0 +1,313 @@ +package com.fitnessapp.backend.squad.service; + +import com.fitnessapp.backend.api.common.ErrorCode; +import com.fitnessapp.backend.nutrition.repository.MealLogRepository; +import com.fitnessapp.backend.squad.SquadException; +import com.fitnessapp.backend.squad.dto.LeaderboardEntry; +import com.fitnessapp.backend.squad.dto.SquadDetailResponse; +import com.fitnessapp.backend.squad.dto.SquadMapper; +import com.fitnessapp.backend.squad.dto.SquadResponse; +import com.fitnessapp.backend.squad.entity.Squad; +import com.fitnessapp.backend.squad.entity.SquadMember; +import com.fitnessapp.backend.squad.repository.SquadMemberRepository; +import com.fitnessapp.backend.squad.repository.SquadRepository; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Squads core service — lifecycle (create / join / leave / list / detail), + * shared streak evaluation, and 7-day leaderboard. + * + *

Service-layer invariants (not enforced by SQL): + *

    + *
  • Max 10 members per squad
  • + *
  • Max 3 active squads per user
  • + *
+ */ +@Service +@Slf4j +@RequiredArgsConstructor +public class SquadService { + + static final int MAX_MEMBERS_PER_SQUAD = 10; + static final int MAX_SQUADS_PER_USER = 3; + static final int LEADERBOARD_WINDOW_DAYS = 7; + /** Members with fewer than this many distinct logged days in the window are tagged "warming up". */ + static final int LEADERBOARD_MIN_ACTIVE_DAYS = 3; + + private final SquadRepository squadRepository; + private final SquadMemberRepository squadMemberRepository; + private final MealLogRepository mealLogRepository; + private final InviteCodeGenerator inviteCodeGenerator; + + // ===================================================================== Lifecycle + + @Transactional + public SquadResponse create(UUID userId, String name, String emoji, String timezone) { + enforceSquadCap(userId); + + Squad squad = Squad.builder() + .name(name.trim()) + .emoji(emoji.trim()) + .inviteCode(inviteCodeGenerator.generateUnique()) + .ownerUserId(userId) + .currentStreak(0) + .longestStreak(0) + .timezone(normalizeTimezone(timezone)) + .build(); + squad = squadRepository.save(squad); + + SquadMember owner = SquadMember.builder() + .squadId(squad.getId()) + .userId(userId) + .role("owner") + .build(); + squadMemberRepository.save(owner); + + log.info("Squad created: id={} owner={} code={}", squad.getId(), userId, squad.getInviteCode()); + return SquadMapper.toResponse(squad, 1); + } + + @Transactional + public SquadResponse joinByCode(UUID userId, String inviteCode) { + Squad squad = squadRepository.findByInviteCode(inviteCode.trim().toUpperCase()) + .orElseThrow(() -> new SquadException(ErrorCode.SQUAD_INVITE_CODE_INVALID)); + + if (squadMemberRepository.existsBySquadIdAndUserId(squad.getId(), userId)) { + throw new SquadException(ErrorCode.SQUAD_ALREADY_MEMBER); + } + + enforceSquadCap(userId); + + long memberCount = squadMemberRepository.countBySquadId(squad.getId()); + if (memberCount >= MAX_MEMBERS_PER_SQUAD) { + throw new SquadException(ErrorCode.SQUAD_FULL); + } + + squadMemberRepository.save(SquadMember.builder() + .squadId(squad.getId()) + .userId(userId) + .role("member") + .build()); + + log.info("User {} joined squad {} (code={})", userId, squad.getId(), squad.getInviteCode()); + return SquadMapper.toResponse(squad, memberCount + 1); + } + + @Transactional(readOnly = true) + public List listForUser(UUID userId) { + List squads = squadRepository.findAllForMember(userId); + List out = new ArrayList<>(squads.size()); + for (Squad s : squads) { + out.add(SquadMapper.toResponse(s, squadMemberRepository.countBySquadId(s.getId()))); + } + return out; + } + + @Transactional(readOnly = true) + public SquadDetailResponse getDetail(UUID userId, UUID squadId) { + Squad squad = squadRepository.findById(squadId) + .orElseThrow(() -> new SquadException(ErrorCode.SQUAD_NOT_FOUND)); + requireMembership(squadId, userId); + + List members = squadMemberRepository.findAllBySquadId(squadId); + SquadResponse response = SquadMapper.toResponse(squad, members.size()); + return new SquadDetailResponse(response, SquadMapper.toMemberSummaries(members)); + } + + @Transactional + public void leave(UUID userId, UUID squadId) { + Squad squad = squadRepository.findById(squadId) + .orElseThrow(() -> new SquadException(ErrorCode.SQUAD_NOT_FOUND)); + if (!squadMemberRepository.existsBySquadIdAndUserId(squadId, userId)) { + throw new SquadException(ErrorCode.SQUAD_ACCESS_DENIED); + } + + long count = squadMemberRepository.countBySquadId(squadId); + squadMemberRepository.deleteBySquadIdAndUserId(squadId, userId); + + if (count <= 1) { + // Last member leaving → tear down the squad + squadRepository.deleteById(squadId); + log.info("Squad {} dissolved by last member {}", squadId, userId); + return; + } + + if (squad.getOwnerUserId().equals(userId)) { + // Promote earliest joiner to owner + List remaining = squadMemberRepository.findAllBySquadId(squadId); + remaining.stream() + .min(Comparator.comparing(SquadMember::getJoinedAt, + Comparator.nullsLast(Comparator.naturalOrder()))) + .ifPresent(m -> { + squad.setOwnerUserId(m.getUserId()); + m.setRole("owner"); + squadMemberRepository.save(m); + squadRepository.save(squad); + log.info("Squad {} ownership transferred to {}", squadId, m.getUserId()); + }); + } + } + + @Transactional + public void removeMember(UUID actorUserId, UUID squadId, UUID targetUserId) { + Squad squad = squadRepository.findById(squadId) + .orElseThrow(() -> new SquadException(ErrorCode.SQUAD_NOT_FOUND)); + if (!squad.getOwnerUserId().equals(actorUserId)) { + throw new SquadException(ErrorCode.SQUAD_ACCESS_DENIED, "Only the squad owner can remove members"); + } + if (actorUserId.equals(targetUserId)) { + // Owner removing themself → use leave() instead so ownership is transferred safely + leave(actorUserId, squadId); + return; + } + if (!squadMemberRepository.existsBySquadIdAndUserId(squadId, targetUserId)) { + throw new SquadException(ErrorCode.SQUAD_NOT_FOUND, "User is not a member of this squad"); + } + squadMemberRepository.deleteBySquadIdAndUserId(squadId, targetUserId); + log.info("User {} removed from squad {} by owner {}", targetUserId, squadId, actorUserId); + } + + // ===================================================================== Membership helpers + + /** + * Returns the set of squad ids a user is a member of. Used by Kudos validation + * to confirm two users share at least one squad. + */ + @Transactional(readOnly = true) + public List squadIdsForUser(UUID userId) { + return squadMemberRepository.findAllByUserId(userId).stream() + .map(SquadMember::getSquadId) + .toList(); + } + + /** + * Returns true if {@code userA} and {@code userB} share at least one squad. + */ + @Transactional(readOnly = true) + public boolean shareSquad(UUID userA, UUID userB) { + if (userA.equals(userB)) return true; + List a = squadIdsForUser(userA); + if (a.isEmpty()) return false; + List b = squadIdsForUser(userB); + for (UUID id : a) if (b.contains(id)) return true; + return false; + } + + // ===================================================================== Streak + + /** + * Evaluate the squad's shared streak for a given day. Idempotent: re-running + * for the same day after lastActiveDay has been advanced is a no-op. + * + *

Rule: if {@code ≥1 member} logged a meal on {@code evaluationDay} (squad + * timezone), increment {@code currentStreak} by 1 and update {@code lastActiveDay}; + * otherwise reset {@code currentStreak} to 0 and preserve {@code longestStreak}. + */ + @Transactional + public void evaluateStreakForDay(UUID squadId, LocalDate evaluationDay) { + Squad squad = squadRepository.findByIdForUpdate(squadId) + .orElseThrow(() -> new SquadException(ErrorCode.SQUAD_NOT_FOUND)); + + // Idempotency guard + if (squad.getLastActiveDay() != null && !evaluationDay.isAfter(squad.getLastActiveDay())) { + log.debug("Squad {} already evaluated through {}", squadId, squad.getLastActiveDay()); + return; + } + + ZoneId zone = ZoneId.of(squad.getTimezone() == null ? "UTC" : squad.getTimezone()); + OffsetDateTime dayStart = evaluationDay.atStartOfDay(zone).toOffsetDateTime(); + OffsetDateTime dayEnd = evaluationDay.plusDays(1).atStartOfDay(zone).toOffsetDateTime(); + + boolean anyLogged = squadMemberRepository.findAllBySquadId(squadId).stream() + .anyMatch(m -> mealLogRepository.existsLogInRange(m.getUserId(), dayStart, dayEnd)); + + int oldStreak = squad.getCurrentStreak() == null ? 0 : squad.getCurrentStreak(); + int newStreak = anyLogged ? oldStreak + 1 : 0; + int longest = Math.max(squad.getLongestStreak() == null ? 0 : squad.getLongestStreak(), newStreak); + + squad.setCurrentStreak(newStreak); + squad.setLongestStreak(longest); + squad.setLastActiveDay(evaluationDay); + squadRepository.save(squad); + + log.info("Squad {} streak evaluation: day={} anyLogged={} streak {}→{} longest={}", + squadId, evaluationDay, anyLogged, oldStreak, newStreak, longest); + } + + // ===================================================================== Leaderboard + + /** + * 7-day leaderboard ordered by meals logged desc. Members with fewer than + * {@link #LEADERBOARD_MIN_ACTIVE_DAYS} distinct logged days appear last and + * are flagged {@code warmingUp = true}. + */ + @Transactional(readOnly = true) + public List leaderboard(UUID actorUserId, UUID squadId) { + requireMembership(squadId, actorUserId); + + OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC).minusDays(LEADERBOARD_WINDOW_DAYS); + List members = squadMemberRepository.findAllBySquadId(squadId); + + record Row(UUID userId, long meals, long days) {} + List rows = new ArrayList<>(members.size()); + for (SquadMember m : members) { + long meals = mealLogRepository.countByUserIdAndConsumedAtAfter(m.getUserId(), since); + long days = mealLogRepository.countDistinctDaysByUserSince(m.getUserId(), since); + rows.add(new Row(m.getUserId(), meals, days)); + } + + // Sort: warming-up users go last; within each group, more meals = higher rank. + rows.sort((a, b) -> { + boolean aw = a.days() < LEADERBOARD_MIN_ACTIVE_DAYS; + boolean bw = b.days() < LEADERBOARD_MIN_ACTIVE_DAYS; + if (aw != bw) return aw ? 1 : -1; + return Long.compare(b.meals(), a.meals()); + }); + + List out = new ArrayList<>(rows.size()); + int rank = 1; + for (Row r : rows) { + boolean warming = r.days() < LEADERBOARD_MIN_ACTIVE_DAYS; + out.add(new LeaderboardEntry(r.userId(), warming ? 0 : rank, r.meals(), r.days(), warming)); + if (!warming) rank++; + } + return Collections.unmodifiableList(out); + } + + // ===================================================================== Internal + + private void enforceSquadCap(UUID userId) { + if (squadMemberRepository.countByUserId(userId) >= MAX_SQUADS_PER_USER) { + throw new SquadException(ErrorCode.SQUAD_LIMIT_REACHED); + } + } + + private void requireMembership(UUID squadId, UUID userId) { + if (!squadMemberRepository.existsBySquadIdAndUserId(squadId, userId)) { + throw new SquadException(ErrorCode.SQUAD_ACCESS_DENIED); + } + } + + private static String normalizeTimezone(String tz) { + if (tz == null || tz.isBlank()) return "UTC"; + try { + return ZoneId.of(tz.trim()).getId(); + } catch (Exception e) { + return "UTC"; + } + } +} diff --git a/backend/src/main/java/com/fitnessapp/backend/squad/service/SquadStreakScheduler.java b/backend/src/main/java/com/fitnessapp/backend/squad/service/SquadStreakScheduler.java new file mode 100644 index 0000000..bcb9412 --- /dev/null +++ b/backend/src/main/java/com/fitnessapp/backend/squad/service/SquadStreakScheduler.java @@ -0,0 +1,74 @@ +package com.fitnessapp.backend.squad.service; + +import com.fitnessapp.backend.squad.entity.Squad; +import com.fitnessapp.backend.squad.repository.SquadRepository; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.List; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Hourly scheduler that evaluates each squad's shared streak shortly after the + * squad's local midnight. Idempotent — re-running within the same local day is + * a no-op (the service guards on {@code lastActiveDay}). + * + *

Why hourly instead of nightly? Squads have per-squad timezones, so the + * "midnight" moment is different per squad. Iterating once per hour and only + * acting when the local hour has rolled over to 0 lets us evaluate every squad + * in its own zone without juggling per-zone cron triggers. + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class SquadStreakScheduler { + + private final SquadRepository squadRepository; + private final SquadService squadService; + + /** Fires at 5 minutes past every hour (UTC). */ + @Scheduled(cron = "${aurafitness.squads.streak-cron:0 5 * * * *}") + public void evaluateAllSquads() { + List squads = squadRepository.findAll(); + if (squads.isEmpty()) return; + + int evaluated = 0; + for (Squad squad : squads) { + try { + if (shouldEvaluateNow(squad)) { + LocalDate yesterday = LocalDate.now(zoneOf(squad)).minusDays(1); + squadService.evaluateStreakForDay(squad.getId(), yesterday); + evaluated++; + } + } catch (Exception e) { + log.error("Squad {} streak evaluation failed", squad.getId(), e); + } + } + if (evaluated > 0) { + log.info("Squad streak job evaluated {} of {} squads", evaluated, squads.size()); + } + } + + private boolean shouldEvaluateNow(Squad squad) { + ZoneId zone = zoneOf(squad); + LocalTime localTime = LocalTime.now(zone); + LocalDate localToday = LocalDate.now(zone); + if (localTime.getHour() != 0) return false; + return squad.getLastActiveDay() == null || squad.getLastActiveDay().isBefore(localToday); + } + + private static ZoneId zoneOf(Squad squad) { + String tz = squad.getTimezone(); + if (tz == null || tz.isBlank()) return ZoneId.of("UTC"); + try { + return ZoneId.of(tz); + } catch (Exception e) { + return ZoneId.of("UTC"); + } + } +} diff --git a/backend/src/main/resources/db/migration/V52__create_squads.sql b/backend/src/main/resources/db/migration/V52__create_squads.sql new file mode 100644 index 0000000..9c43486 --- /dev/null +++ b/backend/src/main/resources/db/migration/V52__create_squads.sql @@ -0,0 +1,54 @@ +-- ============================================================================ +-- V52: Squads — social streak groups, Kudos, leaderboard +-- ============================================================================ +-- Implements feature #220 (Squads). Adapts the Strava Clubs + Duolingo group +-- streak retention pattern to nutrition logging: +-- - 3–10 person groups identified by 6-char invite code +-- - per-meal Kudos (toggle, idempotent) +-- - shared Squad streak (≥1 member logs per day → +1) +-- - 7-day leaderboard ranked by meal-log activity +-- +-- Service-layer rules (NOT enforced by SQL — see SquadService): +-- - max 3 active squads per user +-- - max 10 members per squad +-- - kudos only allowed on meal_log rows from same-squad members within 7d +-- ============================================================================ + +-- Squads +CREATE TABLE squads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(30) NOT NULL, + emoji VARCHAR(8) NOT NULL, + invite_code CHAR(6) NOT NULL UNIQUE, + owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + current_streak INTEGER NOT NULL DEFAULT 0, + longest_streak INTEGER NOT NULL DEFAULT 0, + last_active_day DATE, + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_squads_owner ON squads(owner_user_id); + +-- Squad members (composite PK) +CREATE TABLE squad_members ( + squad_id UUID NOT NULL REFERENCES squads(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(16) NOT NULL DEFAULT 'member', -- 'owner' | 'member' + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (squad_id, user_id) +); + +CREATE INDEX idx_squad_members_user ON squad_members(user_id); +CREATE INDEX idx_squad_members_squad ON squad_members(squad_id); + +-- Kudos on meal logs +CREATE TABLE meal_log_kudos ( + meal_log_id BIGINT NOT NULL REFERENCES meal_log(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (meal_log_id, user_id) +); + +CREATE INDEX idx_meal_log_kudos_meal ON meal_log_kudos(meal_log_id); +CREATE INDEX idx_meal_log_kudos_user ON meal_log_kudos(user_id); diff --git a/backend/src/test/java/com/fitnessapp/backend/squad/service/InviteCodeGeneratorTest.java b/backend/src/test/java/com/fitnessapp/backend/squad/service/InviteCodeGeneratorTest.java new file mode 100644 index 0000000..b34f2fe --- /dev/null +++ b/backend/src/test/java/com/fitnessapp/backend/squad/service/InviteCodeGeneratorTest.java @@ -0,0 +1,58 @@ +package com.fitnessapp.backend.squad.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fitnessapp.backend.squad.repository.SquadRepository; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class InviteCodeGeneratorTest { + + @Mock private SquadRepository squadRepository; + + @Test + void generate_returnsCodeOfExpectedShape() { + InviteCodeGenerator gen = new InviteCodeGenerator(squadRepository); + String code = gen.generate(); + + assertThat(code).hasSize(InviteCodeGenerator.CODE_LENGTH); + for (char c : code.toCharArray()) { + assertThat(InviteCodeGenerator.ALPHABET).contains(String.valueOf(c)); + } + // Confusable characters intentionally omitted + assertThat(code).doesNotContain("0", "O", "1", "I"); + } + + @Test + void generateUnique_retriesUntilCollisionFree() { + InviteCodeGenerator gen = new InviteCodeGenerator(squadRepository); + // First two attempts collide, third succeeds. + when(squadRepository.existsByInviteCode(anyString())) + .thenReturn(true) + .thenReturn(true) + .thenReturn(false); + + String code = gen.generateUnique(); + + assertThat(code).hasSize(InviteCodeGenerator.CODE_LENGTH); + verify(squadRepository, times(3)).existsByInviteCode(anyString()); + } + + @Test + void generateUnique_givesUpAfterMaxAttempts() { + InviteCodeGenerator gen = new InviteCodeGenerator(squadRepository); + when(squadRepository.existsByInviteCode(anyString())).thenReturn(true); + + assertThatThrownBy(gen::generateUnique).isInstanceOf(IllegalStateException.class); + verify(squadRepository, times(InviteCodeGenerator.MAX_ATTEMPTS)).existsByInviteCode(anyString()); + } +} diff --git a/backend/src/test/java/com/fitnessapp/backend/squad/service/KudosServiceTest.java b/backend/src/test/java/com/fitnessapp/backend/squad/service/KudosServiceTest.java new file mode 100644 index 0000000..80d991f --- /dev/null +++ b/backend/src/test/java/com/fitnessapp/backend/squad/service/KudosServiceTest.java @@ -0,0 +1,132 @@ +package com.fitnessapp.backend.squad.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fitnessapp.backend.api.common.ErrorCode; +import com.fitnessapp.backend.nutrition.entity.MealLog; +import com.fitnessapp.backend.nutrition.repository.MealLogRepository; +import com.fitnessapp.backend.squad.SquadException; +import com.fitnessapp.backend.squad.dto.KudosResponse; +import com.fitnessapp.backend.squad.entity.MealLogKudos; +import com.fitnessapp.backend.squad.repository.MealLogKudosRepository; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class KudosServiceTest { + + @Mock private MealLogRepository mealLogRepository; + @Mock private MealLogKudosRepository kudosRepository; + @Mock private SquadService squadService; + + private KudosService service; + + private final UUID actor = UUID.randomUUID(); + private final UUID owner = UUID.randomUUID(); + private final long mealLogId = 42L; + + @BeforeEach + void setUp() { + service = new KudosService(mealLogRepository, kudosRepository, squadService); + } + + @Test + void toggle_addsKudosWhenAbsent() { + MealLog meal = recentMealOwnedBy(owner); + when(mealLogRepository.findById(mealLogId)).thenReturn(Optional.of(meal)); + when(squadService.shareSquad(actor, owner)).thenReturn(true); + when(kudosRepository.existsByMealLogIdAndUserId(mealLogId, actor)).thenReturn(false); + when(kudosRepository.countByMealLogId(mealLogId)).thenReturn(1L); + + KudosResponse response = service.toggle(actor, mealLogId); + + assertThat(response.kudoed()).isTrue(); + assertThat(response.kudosCount()).isEqualTo(1); + verify(kudosRepository).save(any(MealLogKudos.class)); + } + + @Test + void toggle_removesKudosWhenPresent() { + MealLog meal = recentMealOwnedBy(owner); + when(mealLogRepository.findById(mealLogId)).thenReturn(Optional.of(meal)); + when(squadService.shareSquad(actor, owner)).thenReturn(true); + when(kudosRepository.existsByMealLogIdAndUserId(mealLogId, actor)).thenReturn(true); + when(kudosRepository.countByMealLogId(mealLogId)).thenReturn(0L); + + KudosResponse response = service.toggle(actor, mealLogId); + + assertThat(response.kudoed()).isFalse(); + assertThat(response.kudosCount()).isZero(); + verify(kudosRepository).deleteByMealLogIdAndUserId(mealLogId, actor); + verify(kudosRepository, never()).save(any()); + } + + @Test + void toggle_rejectsSelfKudos() { + MealLog meal = recentMealOwnedBy(actor); // actor is also the meal owner + when(mealLogRepository.findById(mealLogId)).thenReturn(Optional.of(meal)); + + assertThatThrownBy(() -> service.toggle(actor, mealLogId)) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.KUDOS_SELF_FORBIDDEN); + } + + @Test + void toggle_rejectsExpiredMeal() { + MealLog meal = MealLog.builder() + .userId(owner) + .consumedAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(8)) // outside 7-day window + .build(); + when(mealLogRepository.findById(mealLogId)).thenReturn(Optional.of(meal)); + + assertThatThrownBy(() -> service.toggle(actor, mealLogId)) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.KUDOS_FORBIDDEN); + } + + @Test + void toggle_rejectsWhenUsersDoNotShareSquad() { + MealLog meal = recentMealOwnedBy(owner); + when(mealLogRepository.findById(mealLogId)).thenReturn(Optional.of(meal)); + when(squadService.shareSquad(actor, owner)).thenReturn(false); + + assertThatThrownBy(() -> service.toggle(actor, mealLogId)) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.KUDOS_FORBIDDEN); + } + + @Test + void toggle_rejectsUnknownMeal() { + when(mealLogRepository.findById(eq(mealLogId))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.toggle(actor, mealLogId)) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.MEAL_NOT_FOUND); + } + + private MealLog recentMealOwnedBy(UUID ownerId) { + return MealLog.builder() + .userId(ownerId) + .consumedAt(OffsetDateTime.now(ZoneOffset.UTC).minusHours(3)) + .build(); + } +} diff --git a/backend/src/test/java/com/fitnessapp/backend/squad/service/SquadServiceTest.java b/backend/src/test/java/com/fitnessapp/backend/squad/service/SquadServiceTest.java new file mode 100644 index 0000000..ddb1ea1 --- /dev/null +++ b/backend/src/test/java/com/fitnessapp/backend/squad/service/SquadServiceTest.java @@ -0,0 +1,362 @@ +package com.fitnessapp.backend.squad.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fitnessapp.backend.api.common.ErrorCode; +import com.fitnessapp.backend.nutrition.repository.MealLogRepository; +import com.fitnessapp.backend.squad.SquadException; +import com.fitnessapp.backend.squad.dto.LeaderboardEntry; +import com.fitnessapp.backend.squad.dto.SquadResponse; +import com.fitnessapp.backend.squad.entity.Squad; +import com.fitnessapp.backend.squad.entity.SquadMember; +import com.fitnessapp.backend.squad.repository.SquadMemberRepository; +import com.fitnessapp.backend.squad.repository.SquadRepository; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SquadServiceTest { + + @Mock private SquadRepository squadRepository; + @Mock private SquadMemberRepository squadMemberRepository; + @Mock private MealLogRepository mealLogRepository; + @Mock private InviteCodeGenerator inviteCodeGenerator; + + private SquadService service; + + private final UUID userA = UUID.randomUUID(); + private final UUID userB = UUID.randomUUID(); + private final UUID squadId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new SquadService(squadRepository, squadMemberRepository, mealLogRepository, inviteCodeGenerator); + } + + // ===================================================================== create + + @Test + void create_persistsSquadAndOwnerMembership() { + when(squadMemberRepository.countByUserId(userA)).thenReturn(0L); + when(inviteCodeGenerator.generateUnique()).thenReturn("ABCD23"); + when(squadRepository.save(any(Squad.class))).thenAnswer(inv -> { + Squad s = inv.getArgument(0); + s.setId(squadId); + return s; + }); + + SquadResponse response = service.create(userA, "Sunrise Eaters", "🌅", "America/New_York"); + + assertThat(response.id()).isEqualTo(squadId); + assertThat(response.inviteCode()).isEqualTo("ABCD23"); + assertThat(response.ownerUserId()).isEqualTo(userA); + assertThat(response.memberCount()).isEqualTo(1); + assertThat(response.timezone()).isEqualTo("America/New_York"); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(SquadMember.class); + verify(squadMemberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getUserId()).isEqualTo(userA); + assertThat(memberCaptor.getValue().getRole()).isEqualTo("owner"); + } + + @Test + void create_invalidTimezone_fallsBackToUTC() { + when(squadMemberRepository.countByUserId(userA)).thenReturn(0L); + when(inviteCodeGenerator.generateUnique()).thenReturn("XYZW34"); + when(squadRepository.save(any(Squad.class))).thenAnswer(inv -> inv.getArgument(0)); + + SquadResponse response = service.create(userA, "Test", "🔥", "Not/A/Zone"); + + assertThat(response.timezone()).isEqualTo("UTC"); + } + + @Test + void create_rejectsWhenUserAlreadyInThreeSquads() { + when(squadMemberRepository.countByUserId(userA)).thenReturn(3L); + + assertThatThrownBy(() -> service.create(userA, "X", "🔥", null)) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.SQUAD_LIMIT_REACHED); + + verify(squadRepository, never()).save(any()); + } + + // ===================================================================== join + + @Test + void joinByCode_addsMemberWhenSquadHasRoom() { + Squad squad = squadFixture(); + when(squadRepository.findByInviteCode("ABCD23")).thenReturn(Optional.of(squad)); + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userB)).thenReturn(false); + when(squadMemberRepository.countByUserId(userB)).thenReturn(0L); + when(squadMemberRepository.countBySquadId(squadId)).thenReturn(2L); + + SquadResponse response = service.joinByCode(userB, "ABCD23"); + + assertThat(response.id()).isEqualTo(squadId); + assertThat(response.memberCount()).isEqualTo(3); + verify(squadMemberRepository).save(any(SquadMember.class)); + } + + @Test + void joinByCode_rejectsWhenSquadFull() { + Squad squad = squadFixture(); + when(squadRepository.findByInviteCode("ABCD23")).thenReturn(Optional.of(squad)); + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userB)).thenReturn(false); + when(squadMemberRepository.countByUserId(userB)).thenReturn(0L); + when(squadMemberRepository.countBySquadId(squadId)).thenReturn(10L); + + assertThatThrownBy(() -> service.joinByCode(userB, "ABCD23")) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.SQUAD_FULL); + + verify(squadMemberRepository, never()).save(any()); + } + + @Test + void joinByCode_rejectsWhenAlreadyMember() { + Squad squad = squadFixture(); + when(squadRepository.findByInviteCode("ABCD23")).thenReturn(Optional.of(squad)); + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userB)).thenReturn(true); + + assertThatThrownBy(() -> service.joinByCode(userB, "ABCD23")) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.SQUAD_ALREADY_MEMBER); + } + + @Test + void joinByCode_rejectsWhenInviteCodeUnknown() { + when(squadRepository.findByInviteCode(any())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.joinByCode(userB, "NOPE99")) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.SQUAD_INVITE_CODE_INVALID); + } + + // ===================================================================== leave + + @Test + void leave_lastMember_dissolvesSquad() { + Squad squad = squadFixture(); + when(squadRepository.findById(squadId)).thenReturn(Optional.of(squad)); + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userA)).thenReturn(true); + when(squadMemberRepository.countBySquadId(squadId)).thenReturn(1L); + + service.leave(userA, squadId); + + verify(squadMemberRepository).deleteBySquadIdAndUserId(squadId, userA); + verify(squadRepository).deleteById(squadId); + } + + @Test + void leave_nonMember_rejected() { + Squad squad = squadFixture(); + when(squadRepository.findById(squadId)).thenReturn(Optional.of(squad)); + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userB)).thenReturn(false); + + assertThatThrownBy(() -> service.leave(userB, squadId)) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.SQUAD_ACCESS_DENIED); + } + + @Test + void leave_ownerWithRemainingMembers_transfersOwnershipToEarliestJoiner() { + Squad squad = squadFixture(); // ownerUserId = userA + UUID userC = UUID.randomUUID(); + when(squadRepository.findById(squadId)).thenReturn(Optional.of(squad)); + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userA)).thenReturn(true); + when(squadMemberRepository.countBySquadId(squadId)).thenReturn(3L); + + // userC joined before userB → userC should be promoted, not userB + SquadMember laterJoiner = SquadMember.builder() + .squadId(squadId).userId(userB).role("member") + .joinedAt(OffsetDateTime.parse("2026-04-02T10:00:00Z")) + .build(); + SquadMember earliestJoiner = SquadMember.builder() + .squadId(squadId).userId(userC).role("member") + .joinedAt(OffsetDateTime.parse("2026-04-01T10:00:00Z")) + .build(); + when(squadMemberRepository.findAllBySquadId(squadId)) + .thenReturn(List.of(laterJoiner, earliestJoiner)); + + service.leave(userA, squadId); + + verify(squadMemberRepository).deleteBySquadIdAndUserId(squadId, userA); + verify(squadRepository, never()).deleteById(any()); + assertThat(squad.getOwnerUserId()).isEqualTo(userC); + assertThat(earliestJoiner.getRole()).isEqualTo("owner"); + assertThat(laterJoiner.getRole()).isEqualTo("member"); + verify(squadMemberRepository).save(earliestJoiner); + verify(squadRepository).save(squad); + } + + @Test + void leave_nonOwnerWithRemainingMembers_doesNotTransferOwnership() { + Squad squad = squadFixture(); // ownerUserId = userA + when(squadRepository.findById(squadId)).thenReturn(Optional.of(squad)); + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userB)).thenReturn(true); + when(squadMemberRepository.countBySquadId(squadId)).thenReturn(2L); + + service.leave(userB, squadId); + + verify(squadMemberRepository).deleteBySquadIdAndUserId(squadId, userB); + verify(squadRepository, never()).deleteById(any()); + verify(squadRepository, never()).save(any()); + assertThat(squad.getOwnerUserId()).isEqualTo(userA); + } + + // ===================================================================== streak + + @Test + void evaluateStreakForDay_incrementsWhenAtLeastOneMemberLogged() { + Squad squad = squadFixture(); + squad.setCurrentStreak(2); + squad.setLongestStreak(2); + when(squadRepository.findByIdForUpdate(squadId)).thenReturn(Optional.of(squad)); + + SquadMember m1 = SquadMember.builder().squadId(squadId).userId(userA).build(); + SquadMember m2 = SquadMember.builder().squadId(squadId).userId(userB).build(); + when(squadMemberRepository.findAllBySquadId(squadId)).thenReturn(List.of(m1, m2)); + // userA logged, userB did not + when(mealLogRepository.existsLogInRange(eq(userA), any(), any())).thenReturn(true); + lenient().when(mealLogRepository.existsLogInRange(eq(userB), any(), any())).thenReturn(false); + + service.evaluateStreakForDay(squadId, LocalDate.of(2026, 5, 5)); + + assertThat(squad.getCurrentStreak()).isEqualTo(3); + assertThat(squad.getLongestStreak()).isEqualTo(3); + assertThat(squad.getLastActiveDay()).isEqualTo(LocalDate.of(2026, 5, 5)); + verify(squadRepository).save(squad); + } + + @Test + void evaluateStreakForDay_resetsWhenNoMemberLogged_butKeepsLongest() { + Squad squad = squadFixture(); + squad.setCurrentStreak(7); + squad.setLongestStreak(7); + when(squadRepository.findByIdForUpdate(squadId)).thenReturn(Optional.of(squad)); + when(squadMemberRepository.findAllBySquadId(squadId)).thenReturn( + List.of(SquadMember.builder().squadId(squadId).userId(userA).build())); + when(mealLogRepository.existsLogInRange(eq(userA), any(), any())).thenReturn(false); + + service.evaluateStreakForDay(squadId, LocalDate.of(2026, 5, 5)); + + assertThat(squad.getCurrentStreak()).isZero(); + assertThat(squad.getLongestStreak()).isEqualTo(7); // preserved + } + + @Test + void evaluateStreakForDay_idempotent_skipsWhenAlreadyEvaluated() { + Squad squad = squadFixture(); + squad.setLastActiveDay(LocalDate.of(2026, 5, 5)); + when(squadRepository.findByIdForUpdate(squadId)).thenReturn(Optional.of(squad)); + + service.evaluateStreakForDay(squadId, LocalDate.of(2026, 5, 5)); + + verify(squadRepository, never()).save(any()); + verify(mealLogRepository, never()).existsLogInRange(any(), any(), any()); + } + + // ===================================================================== leaderboard + + @Test + void leaderboard_warmingUpUsersTrailRanked_andCarryFlag() { + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userA)).thenReturn(true); + SquadMember m1 = SquadMember.builder().squadId(squadId).userId(userA).build(); + SquadMember m2 = SquadMember.builder().squadId(squadId).userId(userB).build(); + when(squadMemberRepository.findAllBySquadId(squadId)).thenReturn(List.of(m1, m2)); + + // userA: 12 meals across 5 days → ranked + when(mealLogRepository.countByUserIdAndConsumedAtAfter(eq(userA), any())).thenReturn(12L); + when(mealLogRepository.countDistinctDaysByUserSince(eq(userA), any())).thenReturn(5L); + // userB: 2 meals across 2 days → warming up (below MIN_ACTIVE_DAYS=3) + when(mealLogRepository.countByUserIdAndConsumedAtAfter(eq(userB), any())).thenReturn(2L); + when(mealLogRepository.countDistinctDaysByUserSince(eq(userB), any())).thenReturn(2L); + + List board = service.leaderboard(userA, squadId); + + assertThat(board).hasSize(2); + LeaderboardEntry first = board.get(0); + LeaderboardEntry second = board.get(1); + assertThat(first.userId()).isEqualTo(userA); + assertThat(first.warmingUp()).isFalse(); + assertThat(first.rank()).isEqualTo(1); + assertThat(second.userId()).isEqualTo(userB); + assertThat(second.warmingUp()).isTrue(); + assertThat(second.rank()).isEqualTo(0); // warming-up users are unranked + } + + @Test + void leaderboard_rejectsNonMember() { + when(squadMemberRepository.existsBySquadIdAndUserId(squadId, userB)).thenReturn(false); + + assertThatThrownBy(() -> service.leaderboard(userB, squadId)) + .isInstanceOf(SquadException.class) + .extracting(e -> ((SquadException) e).getErrorCode()) + .isEqualTo(ErrorCode.SQUAD_ACCESS_DENIED); + } + + // ===================================================================== shareSquad + + @Test + void shareSquad_returnsTrueWhenOverlap() { + SquadMember a1 = SquadMember.builder().squadId(squadId).userId(userA).build(); + SquadMember b1 = SquadMember.builder().squadId(squadId).userId(userB).build(); + when(squadMemberRepository.findAllByUserId(userA)).thenReturn(List.of(a1)); + when(squadMemberRepository.findAllByUserId(userB)).thenReturn(List.of(b1)); + + assertThat(service.shareSquad(userA, userB)).isTrue(); + } + + @Test + void shareSquad_returnsFalseWhenDisjoint() { + SquadMember a1 = SquadMember.builder().squadId(UUID.randomUUID()).userId(userA).build(); + SquadMember b1 = SquadMember.builder().squadId(UUID.randomUUID()).userId(userB).build(); + when(squadMemberRepository.findAllByUserId(userA)).thenReturn(List.of(a1)); + when(squadMemberRepository.findAllByUserId(userB)).thenReturn(List.of(b1)); + + assertThat(service.shareSquad(userA, userB)).isFalse(); + } + + // ===================================================================== fixtures + + private Squad squadFixture() { + return Squad.builder() + .id(squadId) + .name("Sunrise Eaters") + .emoji("🌅") + .inviteCode("ABCD23") + .ownerUserId(userA) + .currentStreak(0) + .longestStreak(0) + .timezone("UTC") + .build(); + } +} diff --git a/frontend/src/components/squads/JoinSquadModal.tsx b/frontend/src/components/squads/JoinSquadModal.tsx new file mode 100644 index 0000000..6c84c5d --- /dev/null +++ b/frontend/src/components/squads/JoinSquadModal.tsx @@ -0,0 +1,173 @@ +/** + * JoinSquadModal — Apple-style 6-character segmented invite-code input. + */ +import * as Haptics from 'expo-haptics'; +import React, { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Modal, + Platform, + Pressable, + StyleSheet, + TextInput, + View, +} from 'react-native'; + +import { Text } from '@/components/Text'; +import { squadsApi } from '@/services/squadsApi'; +import type { Squad } from '@/types/squads'; +import { BRAND_COLORS, spacing } from '@/utils'; + +const CODE_LENGTH = 6; +const CODE_REGEX = /^[A-HJ-NP-Z2-9]+$/i; + +interface JoinSquadModalProps { + visible: boolean; + onClose: () => void; + onJoined: (squad: Squad) => void; +} + +export function JoinSquadModal({ visible, onClose, onJoined }: JoinSquadModalProps) { + const [code, setCode] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!visible) { + setCode(''); + setError(null); + setSubmitting(false); + } + }, [visible]); + + const handleChange = (next: string) => { + const cleaned = next.toUpperCase().replace(/[^A-HJ-NP-Z2-9]/g, '').slice(0, CODE_LENGTH); + setCode(cleaned); + if (error) setError(null); + }; + + const handleSubmit = async () => { + if (code.length !== CODE_LENGTH) { + setError(`Invite code must be ${CODE_LENGTH} characters`); + return; + } + setSubmitting(true); + try { + const squad = await squadsApi.join({ inviteCode: code }); + if (Platform.OS !== 'web') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + } + onJoined(squad); + } catch (err: any) { + setError(err?.message || 'Could not join squad'); + setSubmitting(false); + } + }; + + const handleClose = () => { + if (submitting) return; + onClose(); + }; + + const cells = Array.from({ length: CODE_LENGTH }, (_, i) => code[i] ?? ''); + + return ( + + + {}}> + + Join with code + + Ask a squad mate for the 6-character invite code. + + + inputRef.current?.focus()} style={styles.cellsRow}> + {cells.map((c, i) => ( + + {c} + + ))} + + + + + {error && {error}} + + + {submitting + ? + : Join squad} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' }, + sheet: { + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: spacing.lg, + paddingBottom: spacing.xl, + gap: spacing.sm, + }, + handle: { + width: 36, height: 4, borderRadius: 2, + backgroundColor: 'rgba(0,0,0,0.18)', + alignSelf: 'center', + marginBottom: spacing.sm, + }, + title: { textAlign: 'center' }, + subtitle: { textAlign: 'center', color: BRAND_COLORS.textMuted, marginBottom: spacing.lg }, + cellsRow: { flexDirection: 'row', justifyContent: 'center', gap: spacing.xs }, + cell: { + width: 44, height: 56, borderRadius: 12, + borderWidth: 1.5, + borderColor: 'rgba(0,0,0,0.08)', + backgroundColor: 'rgba(0,0,0,0.02)', + alignItems: 'center', justifyContent: 'center', + }, + cellActive: { borderColor: BRAND_COLORS.primary, backgroundColor: 'rgba(249,115,22,0.06)' }, + cellChar: { fontSize: 22, fontVariant: ['tabular-nums'] }, + hiddenInput: { position: 'absolute', opacity: 0, height: 1, width: 1 }, + error: { + marginTop: spacing.sm, + color: BRAND_COLORS.danger, + backgroundColor: 'rgba(208,92,65,0.08)', + paddingHorizontal: spacing.sm, + paddingVertical: 6, + borderRadius: 8, + textAlign: 'center', + }, + cta: { + marginTop: spacing.lg, + backgroundColor: BRAND_COLORS.primary, + paddingVertical: spacing.md, + borderRadius: 14, + alignItems: 'center', + }, + ctaDisabled: { opacity: 0.5 }, + ctaText: { color: '#FFFFFF' }, +}); + +export default JoinSquadModal; diff --git a/frontend/src/components/squads/KudosButton.tsx b/frontend/src/components/squads/KudosButton.tsx new file mode 100644 index 0000000..c1b0f61 --- /dev/null +++ b/frontend/src/components/squads/KudosButton.tsx @@ -0,0 +1,131 @@ +/** + * KudosButton — toggleable flame button on a meal log card. + * + * Inspired by Strava's Kudos pattern — one tap, count visible. Optimistic UI: + * the flame fills immediately; if the API call fails the state reverts and an + * onError callback fires. + */ +import * as Haptics from 'expo-haptics'; +import { Flame } from 'phosphor-react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSequence, + withSpring, +} from 'react-native-reanimated'; + +import { Text } from '@/components/Text'; +import { squadsApi } from '@/services/squadsApi'; +import { BRAND_COLORS, spacing } from '@/utils'; + +interface KudosButtonProps { + mealLogId: number; + initialCount: number; + initialKudoed: boolean; + /** When false, a tap surfaces an inline disabled hint (e.g. own meal). */ + enabled?: boolean; + onError?: (err: unknown) => void; +} + +export function KudosButton({ + mealLogId, + initialCount, + initialKudoed, + enabled = true, + onError, +}: KudosButtonProps) { + const [count, setCount] = useState(initialCount); + const [kudoed, setKudoed] = useState(initialKudoed); + const [pending, setPending] = useState(false); + const scale = useSharedValue(1); + + useEffect(() => { setCount(initialCount); }, [initialCount]); + useEffect(() => { setKudoed(initialKudoed); }, [initialKudoed]); + + const animateTap = () => { + scale.value = withSequence( + withSpring(0.9, { damping: 14, stiffness: 220 }), + withSpring(1.15, { damping: 12, stiffness: 200 }), + withSpring(1.0, { damping: 12, stiffness: 150 }), + ); + }; + + const onPress = useCallback(async () => { + if (!enabled || pending) return; + + // Optimistic update + const nextKudoed = !kudoed; + const nextCount = Math.max(0, count + (nextKudoed ? 1 : -1)); + setKudoed(nextKudoed); + setCount(nextCount); + animateTap(); + if (Platform.OS !== 'web') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); + } + + setPending(true); + try { + const response = await squadsApi.toggleKudos(mealLogId); + // Reconcile with server-truth (handles e.g. concurrent kudos from another device) + setKudoed(response.kudoed); + setCount(response.kudosCount); + } catch (err) { + // Revert + setKudoed(!nextKudoed); + setCount(count); + onError?.(err); + } finally { + setPending(false); + } + }, [enabled, pending, kudoed, count, mealLogId, onError, scale]); + + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + const tint = kudoed ? BRAND_COLORS.primary : BRAND_COLORS.textMuted; + + return ( + + + + {count > 0 && ( + + {count} + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: 999, + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.08)', + backgroundColor: 'rgba(0,0,0,0.02)', + }, + containerActive: { + backgroundColor: 'rgba(249,115,22,0.10)', + borderColor: 'rgba(249,115,22,0.25)', + }, + count: { + fontSize: 12, + fontVariant: ['tabular-nums'], + }, +}); + +export default KudosButton; diff --git a/frontend/src/components/squads/SquadCard.tsx b/frontend/src/components/squads/SquadCard.tsx new file mode 100644 index 0000000..458cb62 --- /dev/null +++ b/frontend/src/components/squads/SquadCard.tsx @@ -0,0 +1,104 @@ +/** + * SquadCard — list-row card for a Squad. + * + * Inspired by Strava Clubs (member roll-up + activity glance) + AuraFitness + * BentoCard system. Glass morphism is provided by the parent BentoCard. + */ +import { Flame, Users } from 'phosphor-react-native'; +import React from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { BentoCard } from '@/components/common/BentoCard'; +import { Text } from '@/components/Text'; +import type { Squad } from '@/types/squads'; +import { BRAND_COLORS, spacing } from '@/utils'; + +interface SquadCardProps { + squad: Squad; + onPress?: (squad: Squad) => void; +} + +export function SquadCard({ squad, onPress }: SquadCardProps) { + const streakColor = squad.currentStreak >= 7 ? BRAND_COLORS.streak.gold : BRAND_COLORS.streak.orange; + + return ( + onPress?.(squad)} + accessibilityRole="button" + accessibilityLabel={`${squad.name} squad, ${squad.memberCount} members, ${squad.currentStreak} day streak`} + > + + + {squad.emoji} + + {squad.name} + + + + {squad.memberCount} members + + · + CODE {squad.inviteCode} + + + + + + {squad.currentStreak} + + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + emoji: { + fontSize: 28, + lineHeight: 32, + }, + body: { + flex: 1, + gap: 2, + }, + meta: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + metaItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + metaText: { + color: BRAND_COLORS.textMuted, + fontSize: 11, + }, + dot: { + color: BRAND_COLORS.textMuted, + fontSize: 11, + }, + code: { + color: BRAND_COLORS.textMuted, + fontSize: 11, + letterSpacing: 1.2, + fontVariant: ['tabular-nums'], + }, + streak: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: 999, + }, +}); + +export default SquadCard; diff --git a/frontend/src/components/squads/SquadCreateModal.tsx b/frontend/src/components/squads/SquadCreateModal.tsx new file mode 100644 index 0000000..269992f --- /dev/null +++ b/frontend/src/components/squads/SquadCreateModal.tsx @@ -0,0 +1,211 @@ +/** + * SquadCreateModal — emoji picker + name input. Returns the created Squad + * via {@code onCreated} so the parent can refresh and reveal the invite code. + */ +import * as Haptics from 'expo-haptics'; +import React, { useState } from 'react'; +import { + ActivityIndicator, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + TextInput, + View, +} from 'react-native'; + +import { Text } from '@/components/Text'; +import { squadsApi } from '@/services/squadsApi'; +import type { Squad } from '@/types/squads'; +import { BRAND_COLORS, spacing } from '@/utils'; + +const EMOJI_PALETTE = [ + '🌅', '🔥', '🥗', '🥑', '🍳', '🥦', '💪', '🏃', + '🌿', '⚡', '🌊', '🍎', '🍇', '🥕', '🌱', '🦄', +]; + +interface SquadCreateModalProps { + visible: boolean; + onClose: () => void; + onCreated: (squad: Squad) => void; +} + +export function SquadCreateModal({ visible, onClose, onCreated }: SquadCreateModalProps) { + const [name, setName] = useState(''); + const [emoji, setEmoji] = useState(EMOJI_PALETTE[0]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setName(''); + setEmoji(EMOJI_PALETTE[0]); + setError(null); + }; + + const handleSubmit = async () => { + const trimmed = name.trim(); + if (trimmed.length === 0) { + setError('Squad name is required'); + return; + } + if (trimmed.length > 30) { + setError('Squad name must be 30 characters or fewer'); + return; + } + setSubmitting(true); + setError(null); + try { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const squad = await squadsApi.create({ name: trimmed, emoji, timezone }); + if (Platform.OS !== 'web') { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + } + onCreated(squad); + reset(); + } catch (err: any) { + setError(err?.message || 'Could not create squad'); + } finally { + setSubmitting(false); + } + }; + + const handleClose = () => { + if (submitting) return; + reset(); + onClose(); + }; + + return ( + + + {}}> + + Create a Squad + + 3–10 friends, one shared streak. Anyone logging keeps the streak alive. + + + EMOJI + + {EMOJI_PALETTE.map((e) => ( + setEmoji(e)} + style={[styles.emojiChip, e === emoji && styles.emojiChipActive]} + accessibilityRole="button" + accessibilityLabel={`Choose emoji ${e}`} + > + {e} + + ))} + + + NAME + + {name.length}/30 + + {error && {error}} + + + {submitting + ? + : Create squad} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.45)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: spacing.lg, + paddingBottom: spacing.xl, + gap: spacing.sm, + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: 'rgba(0,0,0,0.18)', + alignSelf: 'center', + marginBottom: spacing.sm, + }, + title: { textAlign: 'center' }, + subtitle: { textAlign: 'center', color: BRAND_COLORS.textMuted, marginBottom: spacing.md }, + label: { + marginTop: spacing.sm, + color: BRAND_COLORS.textMuted, + letterSpacing: 1.2, + fontSize: 10, + }, + emojiRow: { flexGrow: 0, marginVertical: spacing.xs }, + emojiChip: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs, + backgroundColor: 'rgba(0,0,0,0.04)', + borderWidth: 1, + borderColor: 'transparent', + }, + emojiChipActive: { + backgroundColor: 'rgba(249,115,22,0.10)', + borderColor: BRAND_COLORS.primary, + }, + emojiText: { fontSize: 22 }, + input: { + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.10)', + borderRadius: 12, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + fontSize: 16, + color: BRAND_COLORS.textPrimary, + }, + helper: { color: BRAND_COLORS.textMuted, alignSelf: 'flex-end', fontSize: 10 }, + error: { + color: BRAND_COLORS.danger, + backgroundColor: 'rgba(208,92,65,0.08)', + paddingHorizontal: spacing.sm, + paddingVertical: 6, + borderRadius: 8, + }, + cta: { + marginTop: spacing.md, + backgroundColor: BRAND_COLORS.primary, + paddingVertical: spacing.md, + borderRadius: 14, + alignItems: 'center', + }, + ctaDisabled: { opacity: 0.6 }, + ctaText: { color: '#FFFFFF' }, +}); + +export default SquadCreateModal; diff --git a/frontend/src/components/squads/SquadStreakHeader.tsx b/frontend/src/components/squads/SquadStreakHeader.tsx new file mode 100644 index 0000000..7f250ef --- /dev/null +++ b/frontend/src/components/squads/SquadStreakHeader.tsx @@ -0,0 +1,67 @@ +/** + * SquadStreakHeader — large flame + streak count for the SquadDetail hero. + * Reuses the personal {@code StreakBadge} tier system for color / glow logic. + */ +import { Flame } from 'phosphor-react-native'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/Text'; +import { BRAND_COLORS, spacing } from '@/utils'; + +function tier(streak: number) { + if (streak >= 30) return { label: 'Legendary', color: BRAND_COLORS.streak.red }; + if (streak >= 14) return { label: 'Unstoppable', color: BRAND_COLORS.streak.orange }; + if (streak >= 7) return { label: 'On Fire', color: BRAND_COLORS.streak.gold }; + if (streak >= 3) return { label: 'Warming Up', color: BRAND_COLORS.streak.orange }; + return { label: '', color: BRAND_COLORS.streak.gray }; +} + +interface SquadStreakHeaderProps { + emoji: string; + name: string; + currentStreak: number; + longestStreak: number; + memberCount: number; +} + +export function SquadStreakHeader(props: SquadStreakHeaderProps) { + const t = tier(props.currentStreak); + return ( + + {props.emoji} + {props.name} + + + {props.currentStreak} + day streak + + {t.label.length > 0 && ( + + {t.label.toUpperCase()} + + )} + + {props.memberCount} members · longest {props.longestStreak}d + + + ); +} + +const styles = StyleSheet.create({ + wrap: { alignItems: 'center', gap: 4, paddingVertical: spacing.lg }, + emoji: { fontSize: 48, lineHeight: 54 }, + name: { fontSize: 22, marginTop: spacing.xs }, + streakRow: { + flexDirection: 'row', + alignItems: 'baseline', + gap: spacing.xs, + marginTop: spacing.sm, + }, + streakNumber: { fontSize: 56, fontWeight: '900', letterSpacing: -1, fontVariant: ['tabular-nums'] }, + streakUnit: { letterSpacing: 1 }, + tierLabel: { fontSize: 10, letterSpacing: 1.5 }, + subtle: { color: BRAND_COLORS.textMuted, marginTop: spacing.xs }, +}); + +export default SquadStreakHeader; diff --git a/frontend/src/components/squads/__tests__/KudosButton.test.tsx b/frontend/src/components/squads/__tests__/KudosButton.test.tsx new file mode 100644 index 0000000..0a53256 --- /dev/null +++ b/frontend/src/components/squads/__tests__/KudosButton.test.tsx @@ -0,0 +1,86 @@ +/** + * KudosButton unit tests. + * + * Covers: optimistic toggle, server reconciliation, rapid double-tap debounce + * (via the in-flight `pending` flag), revert on API error. + */ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { KudosButton } from '../KudosButton'; +import { squadsApi } from '@/services/squadsApi'; + +jest.mock('@/services/squadsApi', () => ({ + squadsApi: { toggleKudos: jest.fn() }, +})); + +jest.mock('expo-haptics', () => ({ + impactAsync: jest.fn().mockResolvedValue(undefined), + ImpactFeedbackStyle: { Light: 'light' }, +})); + +describe('KudosButton', () => { + const toggleSpy = squadsApi.toggleKudos as jest.Mock; + + beforeEach(() => { + toggleSpy.mockReset(); + }); + + it('toggles kudoed state optimistically and reconciles with server', async () => { + toggleSpy.mockResolvedValueOnce({ mealLogId: 1, kudosCount: 4, kudoed: true }); + + const { getByTestId, getByText } = render( + , + ); + + fireEvent.press(getByTestId('kudos-button-1')); + + // Optimistic + expect(getByText('4')).toBeTruthy(); + + // Server reconciliation lands the same number + await waitFor(() => expect(toggleSpy).toHaveBeenCalledTimes(1)); + expect(getByText('4')).toBeTruthy(); + }); + + it('reverts state when API call fails', async () => { + toggleSpy.mockRejectedValueOnce(new Error('network')); + const onError = jest.fn(); + + const { getByTestId, queryByText, getByText } = render( + , + ); + + fireEvent.press(getByTestId('kudos-button-2')); + expect(getByText('6')).toBeTruthy(); // optimistic +1 + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + // Reverted to original count, original kudoed=false (button not "active") + expect(getByText('5')).toBeTruthy(); + expect(queryByText('6')).toBeNull(); + }); + + it('only fires one network call on rapid double-tap (in-flight debounce)', async () => { + toggleSpy.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ mealLogId: 3, kudosCount: 1, kudoed: true }), 30)), + ); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('kudos-button-3')); + fireEvent.press(getByTestId('kudos-button-3')); + fireEvent.press(getByTestId('kudos-button-3')); + + await waitFor(() => expect(toggleSpy).toHaveBeenCalledTimes(1)); + }); + + it('does not fire when disabled (e.g. own meal)', () => { + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('kudos-button-4')); + expect(toggleSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/navigation/AppNavigator.tsx b/frontend/src/navigation/AppNavigator.tsx index 8e77fd6..cba4865 100644 --- a/frontend/src/navigation/AppNavigator.tsx +++ b/frontend/src/navigation/AppNavigator.tsx @@ -49,6 +49,8 @@ import { HelpScreen } from '@/screens/HelpScreen'; import { ManageAccountScreen } from '@/screens/ManageAccountScreen'; import { WeeklyInsightsScreen } from '@/screens/WeeklyInsightsScreen'; import { WorkoutsScreen } from '@/screens/WorkoutsScreen'; +import SquadsScreen from '@/screens/SquadsScreen'; +import SquadDetailScreen from '@/screens/SquadDetailScreen'; import { BRAND_COLORS, TAB_ICON_SIZE, useResponsive, useSidebarVisible } from '@/utils'; // Wrap screens with ErrorBoundary to prevent white screen crashes @@ -78,6 +80,8 @@ const SafeBuildPlanScreen = withErrorBoundary(BuildPlanScreen, 'BuildPlan'); const SafeHelpScreen = withErrorBoundary(HelpScreen, 'Help'); const SafeManageAccountScreen = withErrorBoundary(ManageAccountScreen, 'ManageAccount'); const SafeOnboardingScreen = withErrorBoundary(OnboardingScreen, 'Onboarding'); +const SafeSquadsScreen = withErrorBoundary(SquadsScreen, 'Squads'); +const SafeSquadDetailScreen = withErrorBoundary(SquadDetailScreen, 'SquadDetail'); const Tab = createBottomTabNavigator(); // Use createStackNavigator instead of createNativeStackNavigator for Web compatibility @@ -663,6 +667,16 @@ export const AppNavigator = () => { component={SafeBuildPlanScreen} options={{ cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS }} /> + + ); diff --git a/frontend/src/screens/SquadDetailScreen.tsx b/frontend/src/screens/SquadDetailScreen.tsx new file mode 100644 index 0000000..e49a8ee --- /dev/null +++ b/frontend/src/screens/SquadDetailScreen.tsx @@ -0,0 +1,221 @@ +/** + * SquadDetailScreen — single squad header + members + 7-day leaderboard. + * + * Implements US-1.4 (leaderboard) and US-1.5 (leave) from issue #220. + */ +import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import * as Haptics from 'expo-haptics'; +import { CaretLeft, ShareNetwork, SignOut } from 'phosphor-react-native'; +import React, { useCallback } from 'react'; +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + ScrollView, + Share, + StyleSheet, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { BentoCard } from '@/components/common/BentoCard'; +import SquadStreakHeader from '@/components/squads/SquadStreakHeader'; +import { Text } from '@/components/Text'; +import { squadsApi } from '@/services/squadsApi'; +import { BRAND_COLORS, spacing } from '@/utils'; + +type Params = { SquadDetail: { squadId: string } }; + +export function SquadDetailScreen() { + const route = useRoute>(); + const navigation = useNavigation(); + const queryClient = useQueryClient(); + const { squadId } = route.params; + + const detailQuery = useQuery({ + queryKey: ['squads', 'detail', squadId], + queryFn: () => squadsApi.detail(squadId), + }); + + const leaderboardQuery = useQuery({ + queryKey: ['squads', 'leaderboard', squadId], + queryFn: () => squadsApi.leaderboard(squadId), + refetchInterval: 5 * 60 * 1000, // refresh every 5 min while open (per AC-4) + }); + + const leaveMutation = useMutation({ + mutationFn: () => squadsApi.leave(squadId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['squads', 'list'] }); + navigation.goBack(); + }, + }); + + const handleShare = useCallback(async () => { + if (!detailQuery.data) return; + const { name, inviteCode } = detailQuery.data.squad; + if (Platform.OS !== 'web') { + Haptics.selectionAsync().catch(() => {}); + } + try { + await Share.share({ + message: `Join my AuraFitness squad "${name}" — code ${inviteCode}`, + }); + } catch { + /* user cancelled */ + } + }, [detailQuery.data]); + + const handleLeave = useCallback(() => { + Alert.alert( + 'Leave squad?', + 'You can rejoin later with the invite code if you change your mind.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Leave', style: 'destructive', onPress: () => leaveMutation.mutate() }, + ], + ); + }, [leaveMutation]); + + if (detailQuery.isLoading) { + return ( + + + + ); + } + + if (detailQuery.error || !detailQuery.data) { + return ( + + + Could not load squad. + + + ); + } + + const { squad, members } = detailQuery.data; + const leaderboard = leaderboardQuery.data ?? []; + + return ( + + + navigation.goBack()} hitSlop={8} style={styles.iconButton}> + + + + + + + + + + + + INVITE CODE + {squad.inviteCode} + + + + 7-DAY LEADERBOARD + + {leaderboardQuery.isLoading ? ( + + ) : leaderboard.length === 0 ? ( + No activity in the last 7 days. + ) : ( + + {leaderboard.map((entry) => ( + + + {entry.warmingUp ? '–' : `#${entry.rank}`} + + + {shortId(entry.userId)} + + + {entry.warmingUp + ? 'Warming up' + : `${entry.mealsLogged} meals · ${entry.daysActive}d active`} + + + ))} + + )} + + + + MEMBERS · {members.length} + + + + {members.map((m) => ( + + {shortId(m.userId)} + {m.role.toUpperCase()} + + ))} + + + + + ); +} + +function shortId(uuid: string): string { + return uuid.slice(0, 8); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: BRAND_COLORS.background }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + topBar: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingTop: spacing.xs, + paddingBottom: spacing.sm, + }, + iconButton: { padding: spacing.xs }, + scroll: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl, gap: spacing.md }, + codeRow: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: 999, + backgroundColor: 'rgba(0,0,0,0.04)', + }, + codeLabel: { color: BRAND_COLORS.textMuted, letterSpacing: 1.2, fontSize: 10 }, + codeValue: { letterSpacing: 1.4, fontVariant: ['tabular-nums'] }, + sectionLabel: { + color: BRAND_COLORS.textMuted, + letterSpacing: 1.2, + fontSize: 10, + marginTop: spacing.sm, + }, + leaderboardList: { gap: spacing.sm }, + leaderRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + rank: { width: 32, color: BRAND_COLORS.primary, fontVariant: ['tabular-nums'] }, + rowName: { flex: 1, fontVariant: ['tabular-nums'] }, + rowMeta: { color: BRAND_COLORS.textMuted, fontSize: 11 }, + memberList: { gap: spacing.sm }, + memberRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + memberName: { flex: 1, fontVariant: ['tabular-nums'] }, + memberRole: { color: BRAND_COLORS.textMuted, letterSpacing: 1, fontSize: 10 }, + muted: { color: BRAND_COLORS.textMuted }, + error: { color: BRAND_COLORS.danger }, +}); + +export default SquadDetailScreen; diff --git a/frontend/src/screens/SquadsScreen.tsx b/frontend/src/screens/SquadsScreen.tsx new file mode 100644 index 0000000..5af33da --- /dev/null +++ b/frontend/src/screens/SquadsScreen.tsx @@ -0,0 +1,167 @@ +/** + * SquadsScreen — list of the user's squads, with create/join CTAs. + * + * Implements user stories US-1.1 / 1.2 / 1.3 from issue #220 (lifecycle). + * The detail/leaderboard surface lives in {@link SquadDetailScreen}. + */ +import { useNavigation } from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Plus, UserPlus } from 'phosphor-react-native'; +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + StyleSheet, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import JoinSquadModal from '@/components/squads/JoinSquadModal'; +import SquadCard from '@/components/squads/SquadCard'; +import SquadCreateModal from '@/components/squads/SquadCreateModal'; +import { Text } from '@/components/Text'; +import { squadsApi } from '@/services/squadsApi'; +import type { Squad } from '@/types/squads'; +import { BRAND_COLORS, spacing } from '@/utils'; + +const QUERY_KEY = ['squads', 'list'] as const; + +export function SquadsScreen() { + const navigation = useNavigation>(); + const queryClient = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + const [joinOpen, setJoinOpen] = useState(false); + + const { data, isLoading, isRefetching, refetch, error } = useQuery({ + queryKey: QUERY_KEY, + queryFn: squadsApi.list, + staleTime: 30_000, + }); + + const onSquadCreated = useCallback( + (squad: Squad) => { + setCreateOpen(false); + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + navigation.navigate('SquadDetail', { squadId: squad.id }); + }, + [navigation, queryClient], + ); + + const onSquadJoined = useCallback( + (squad: Squad) => { + setJoinOpen(false); + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + navigation.navigate('SquadDetail', { squadId: squad.id }); + }, + [navigation, queryClient], + ); + + const onCardPress = useCallback( + (squad: Squad) => navigation.navigate('SquadDetail', { squadId: squad.id }), + [navigation], + ); + + const squads = data ?? []; + const showEmpty = !isLoading && squads.length === 0; + + return ( + + + Squads + + Log alongside friends. Anyone logging keeps the streak alive. + + + + + setCreateOpen(true)}> + + Create + + setJoinOpen(true)}> + + Join with code + + + + {isLoading ? ( + + ) : error ? ( + + Could not load squads. Pull to retry. + + ) : showEmpty ? ( + + 🌅 + No squads yet + + Create one to invite friends, or join with a 6-character code. + + + ) : ( + s.id} + contentContainerStyle={styles.listContent} + ItemSeparatorComponent={() => } + renderItem={({ item }) => } + refreshControl={ + refetch()} tintColor={BRAND_COLORS.primary} /> + } + /> + )} + + setCreateOpen(false)} + onCreated={onSquadCreated} + /> + setJoinOpen(false)} + onJoined={onSquadJoined} + /> + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: BRAND_COLORS.background }, + header: { paddingHorizontal: spacing.lg, paddingTop: spacing.md, paddingBottom: spacing.sm, gap: 4 }, + h1: { fontSize: 28 }, + subtitle: { color: BRAND_COLORS.textMuted }, + ctaRow: { + flexDirection: 'row', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, + cta: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingVertical: spacing.sm + 2, + borderRadius: 12, + }, + ctaPrimary: { backgroundColor: BRAND_COLORS.primary }, + ctaPrimaryText: { color: '#FFFFFF' }, + ctaSecondary: { + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: 'rgba(0,0,0,0.08)', + }, + ctaSecondaryText: { color: BRAND_COLORS.textPrimary }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: spacing.xl }, + emptyEmoji: { fontSize: 56 }, + emptyHint: { textAlign: 'center', color: BRAND_COLORS.textMuted, marginTop: spacing.xs }, + error: { color: BRAND_COLORS.danger, textAlign: 'center', paddingHorizontal: spacing.xl }, + listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl }, +}); + +export default SquadsScreen; diff --git a/frontend/src/services/squadsApi.ts b/frontend/src/services/squadsApi.ts new file mode 100644 index 0000000..8939973 --- /dev/null +++ b/frontend/src/services/squadsApi.ts @@ -0,0 +1,51 @@ +/** + * Squads API — feature #220. + * + * The shared `apiClient` already unwraps `ApiEnvelope` so each helper + * resolves directly to the inner DTO shape from the backend. + */ +import { api } from './apiClient'; +import type { + CreateSquadPayload, + JoinSquadPayload, + KudosResponse, + LeaderboardEntry, + Squad, + SquadDetail, +} from '@/types/squads'; + +const list = (): Promise => api.get('/api/v1/squads'); + +const detail = (squadId: string): Promise => + api.get(`/api/v1/squads/${squadId}`); + +const create = (payload: CreateSquadPayload): Promise => + api.post('/api/v1/squads', payload); + +const join = (payload: JoinSquadPayload): Promise => + api.post('/api/v1/squads/join', payload); + +const leave = (squadId: string): Promise => + api.post(`/api/v1/squads/${squadId}/leave`); + +const removeMember = (squadId: string, targetUserId: string): Promise => + api.delete(`/api/v1/squads/${squadId}/members/${targetUserId}`); + +const leaderboard = (squadId: string): Promise => + api.get(`/api/v1/squads/${squadId}/leaderboard`); + +const toggleKudos = (mealLogId: number): Promise => + api.post(`/api/v1/meal-logs/${mealLogId}/kudos`); + +export const squadsApi = { + list, + detail, + create, + join, + leave, + removeMember, + leaderboard, + toggleKudos, +}; + +export default squadsApi; diff --git a/frontend/src/types/squads.ts b/frontend/src/types/squads.ts new file mode 100644 index 0000000..892c438 --- /dev/null +++ b/frontend/src/types/squads.ts @@ -0,0 +1,56 @@ +/** + * Squads — feature #220. + * + * Field shapes mirror the Spring Boot DTOs in + * com.fitnessapp.backend.squad.dto.* — the API client unwraps the + * `ApiEnvelope` so consumers see `T` directly. + */ + +export interface Squad { + id: string; + name: string; + emoji: string; + inviteCode: string; + ownerUserId: string; + memberCount: number; + currentStreak: number; + longestStreak: number; + lastActiveDay: string | null; + timezone: string; + createdAt: string | null; +} + +export interface SquadMemberSummary { + userId: string; + role: 'owner' | 'member'; + joinedAt: string | null; +} + +export interface SquadDetail { + squad: Squad; + members: SquadMemberSummary[]; +} + +export interface LeaderboardEntry { + userId: string; + rank: number; // 0 means warming up (unranked) + mealsLogged: number; + daysActive: number; + warmingUp: boolean; +} + +export interface KudosResponse { + mealLogId: number; + kudosCount: number; + kudoed: boolean; +} + +export interface CreateSquadPayload { + name: string; + emoji: string; + timezone?: string; +} + +export interface JoinSquadPayload { + inviteCode: string; +}