From f697996dee37a0cbaf8b65813b972df46cc54a71 Mon Sep 17 00:00:00 2001 From: Elia LIU Date: Wed, 6 May 2026 22:40:00 +1000 Subject: [PATCH 1/2] feat(squads): social streak groups, Kudos toggle, 7-day leaderboard (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements feature 1 of 3 from issue #220 — adapts the Strava Clubs + Duolingo group-streak retention pattern to nutrition logging. Backend (Spring Boot, Java 21): - V52 migration: squads, squad_members (composite PK), meal_log_kudos - 8 new ErrorCodes (2010–2017) wired into GlobalExceptionHandler - SquadService: create / join / leave / list / detail / removeMember, shared-streak evaluation, 7-day leaderboard with Warming Up tier - KudosService: toggle with self-/expiry-/cross-squad validation - SquadStreakScheduler: hourly per-squad-timezone evaluation, idempotent - InviteCodeGenerator: 6-char codes from a 32-char unambiguous alphabet - 18 Mockito unit tests covering AC-1..AC-5 Frontend (React Native + Expo): - Squads list screen with Create / Join CTAs and pull-to-refresh - Squad detail screen with streak header, share-via-system, leaderboard - KudosButton component (optimistic UI, in-flight debounce, revert on error) - SquadCreateModal (emoji palette + name) and JoinSquadModal (segmented code) - 4 Jest tests for KudosButton (toggle, revert, debounce, disabled) Routes registered in AppNavigator; entry-point wiring from Profile is a small follow-up. No design-system tokens were introduced — all theming goes through BRAND_COLORS / spacing per CLAUDE.md. Closes #220 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/FitnessAppApplication.java | 3 + .../backend/api/common/ErrorCode.java | 10 + .../api/common/GlobalExceptionHandler.java | 15 + .../repository/MealLogRepository.java | 18 + .../backend/squad/SquadException.java | 25 ++ .../squad/controller/KudosController.java | 26 ++ .../squad/controller/SquadController.java | 73 ++++ .../backend/squad/dto/CreateSquadRequest.java | 10 + .../backend/squad/dto/JoinSquadRequest.java | 8 + .../backend/squad/dto/KudosResponse.java | 7 + .../backend/squad/dto/LeaderboardEntry.java | 11 + .../squad/dto/SquadDetailResponse.java | 8 + .../backend/squad/dto/SquadMapper.java | 34 ++ .../backend/squad/dto/SquadMemberSummary.java | 10 + .../backend/squad/dto/SquadResponse.java | 19 ++ .../backend/squad/entity/MealLogKudos.java | 28 ++ .../backend/squad/entity/MealLogKudosId.java | 26 ++ .../backend/squad/entity/Squad.java | 52 +++ .../backend/squad/entity/SquadMember.java | 32 ++ .../backend/squad/entity/SquadMemberId.java | 26 ++ .../repository/MealLogKudosRepository.java | 15 + .../repository/SquadMemberRepository.java | 22 ++ .../squad/repository/SquadRepository.java | 31 ++ .../squad/service/InviteCodeGenerator.java | 41 +++ .../backend/squad/service/KudosService.java | 77 +++++ .../backend/squad/service/SquadService.java | 313 +++++++++++++++++ .../squad/service/SquadStreakScheduler.java | 74 ++++ .../db/migration/V52__create_squads.sql | 54 +++ .../service/InviteCodeGeneratorTest.java | 58 ++++ .../squad/service/KudosServiceTest.java | 132 ++++++++ .../squad/service/SquadServiceTest.java | 316 ++++++++++++++++++ .../src/components/squads/JoinSquadModal.tsx | 173 ++++++++++ .../src/components/squads/KudosButton.tsx | 131 ++++++++ frontend/src/components/squads/SquadCard.tsx | 104 ++++++ .../components/squads/SquadCreateModal.tsx | 211 ++++++++++++ .../components/squads/SquadStreakHeader.tsx | 67 ++++ .../squads/__tests__/KudosButton.test.tsx | 86 +++++ frontend/src/navigation/AppNavigator.tsx | 14 + frontend/src/screens/SquadDetailScreen.tsx | 221 ++++++++++++ frontend/src/screens/SquadsScreen.tsx | 167 +++++++++ frontend/src/services/squadsApi.ts | 51 +++ frontend/src/types/squads.ts | 56 ++++ 42 files changed, 2855 insertions(+) create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/SquadException.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/controller/KudosController.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/controller/SquadController.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/CreateSquadRequest.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/JoinSquadRequest.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/KudosResponse.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/LeaderboardEntry.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadDetailResponse.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMapper.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadMemberSummary.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/dto/SquadResponse.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudos.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/entity/MealLogKudosId.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/entity/Squad.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMember.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/entity/SquadMemberId.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/repository/MealLogKudosRepository.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadMemberRepository.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/repository/SquadRepository.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/service/InviteCodeGenerator.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/service/KudosService.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/service/SquadService.java create mode 100644 backend/src/main/java/com/fitnessapp/backend/squad/service/SquadStreakScheduler.java create mode 100644 backend/src/main/resources/db/migration/V52__create_squads.sql create mode 100644 backend/src/test/java/com/fitnessapp/backend/squad/service/InviteCodeGeneratorTest.java create mode 100644 backend/src/test/java/com/fitnessapp/backend/squad/service/KudosServiceTest.java create mode 100644 backend/src/test/java/com/fitnessapp/backend/squad/service/SquadServiceTest.java create mode 100644 frontend/src/components/squads/JoinSquadModal.tsx create mode 100644 frontend/src/components/squads/KudosButton.tsx create mode 100644 frontend/src/components/squads/SquadCard.tsx create mode 100644 frontend/src/components/squads/SquadCreateModal.tsx create mode 100644 frontend/src/components/squads/SquadStreakHeader.tsx create mode 100644 frontend/src/components/squads/__tests__/KudosButton.test.tsx create mode 100644 frontend/src/screens/SquadDetailScreen.tsx create mode 100644 frontend/src/screens/SquadsScreen.tsx create mode 100644 frontend/src/services/squadsApi.ts create mode 100644 frontend/src/types/squads.ts diff --git a/backend/src/main/java/com/fitnessapp/backend/FitnessAppApplication.java b/backend/src/main/java/com/fitnessapp/backend/FitnessAppApplication.java index 103af74c..2b8cf313 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 db5cb455..bb6fc0a2 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 96f47a1d..f6da7f2d 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 a12c9d10..6c941f41 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 00000000..5d0b1f87 --- /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 00000000..e053f2f8 --- /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 00000000..cbeb5199 --- /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 00000000..e163092e --- /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 00000000..96ee74d1 --- /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 00000000..df681987 --- /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 00000000..4a72d8e3 --- /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 00000000..64466313 --- /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 00000000..a68c3211 --- /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 00000000..d3c0264c --- /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 00000000..671fb090 --- /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 00000000..d69e77d3 --- /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 00000000..8df4af51 --- /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 00000000..9a19e67c --- /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 00000000..19aa8d92 --- /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 00000000..603b1ca0 --- /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 00000000..b4c68d83 --- /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 00000000..fe721f00 --- /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 00000000..55c89aba --- /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 00000000..20860972 --- /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 00000000..b0b34002 --- /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 00000000..346916c3 --- /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 00000000..bcb94124 --- /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 00000000..9c434860 --- /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 00000000..b34f2fe1 --- /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 00000000..80d991f9 --- /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 00000000..888ee23f --- /dev/null +++ b/backend/src/test/java/com/fitnessapp/backend/squad/service/SquadServiceTest.java @@ -0,0 +1,316 @@ +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); + } + + // ===================================================================== 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 00000000..6c84c5d8 --- /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 00000000..c1b0f619 --- /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 00000000..458cb624 --- /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 00000000..269992f3 --- /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 00000000..7f250ef1 --- /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 00000000..0a532563 --- /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 8e77fd6d..cba48655 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 00000000..e49a8ee4 --- /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 00000000..5af33da6 --- /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 00000000..89399736 --- /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 00000000..892c438a --- /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; +} From 51bff4aedbb4651f7d0c9fcf28d60e98618ed552 Mon Sep 17 00:00:00 2001 From: Elia LIU Date: Thu, 7 May 2026 17:09:05 +1000 Subject: [PATCH 2/2] test(squads): cover owner-transfer on leave (PR #223 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gemini-code-assist's review on PR #223: SquadService.leave() contains the only place where ownership transfers to the earliest joiner when the current owner leaves with members remaining. That branch was previously uncovered. Adds two tests in SquadServiceTest: - leave_ownerWithRemainingMembers_transfersOwnershipToEarliestJoiner — three members; owner leaves; the earliest-joinedAt member is promoted (verified against a deliberately out-of-order findAllBySquadId list so the test catches a regression where ordering is taken from the list instead of from joinedAt). Verifies the new owner is persisted, the promoted member's role flips to "owner", and the squad is not dissolved. - leave_nonOwnerWithRemainingMembers_doesNotTransferOwnership — guard test: when a non-owner leaves, no ownership transfer occurs and squadRepository.save() is never called. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../squad/service/SquadServiceTest.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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 index 888ee23f..ddb1ea19 100644 --- a/backend/src/test/java/com/fitnessapp/backend/squad/service/SquadServiceTest.java +++ b/backend/src/test/java/com/fitnessapp/backend/squad/service/SquadServiceTest.java @@ -186,6 +186,52 @@ void leave_nonMember_rejected() { .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