diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/JobSearchActivityPersistenceAdapter.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/JobSearchActivityPersistenceAdapter.java new file mode 100644 index 00000000..f531f7ac --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/JobSearchActivityPersistenceAdapter.java @@ -0,0 +1,36 @@ +package com.autoapplicant.adapter.persistence.adapter; + +import com.autoapplicant.adapter.persistence.repository.ApplicationStatusEventJpaRepository; +import com.autoapplicant.adapter.persistence.repository.GeneratedDocumentJpaRepository; +import com.autoapplicant.adapter.persistence.repository.OutreachContactJpaRepository; +import com.autoapplicant.port.out.analytics.JobSearchActivityPort; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class JobSearchActivityPersistenceAdapter implements JobSearchActivityPort { + + private final ApplicationStatusEventJpaRepository statusEvents; + private final GeneratedDocumentJpaRepository documents; + private final OutreachContactJpaRepository outreach; + + public JobSearchActivityPersistenceAdapter(ApplicationStatusEventJpaRepository statusEvents, + GeneratedDocumentJpaRepository documents, + OutreachContactJpaRepository outreach) { + this.statusEvents = statusEvents; + this.documents = documents; + this.outreach = outreach; + } + + @Override + public List findActivitySince(UUID userId, Instant since) { + List activity = new ArrayList<>(statusEvents.findOccurredAtSince(userId, since)); + activity.addAll(documents.findCreatedAtSince(userId, since)); + activity.addAll(outreach.findCreatedAtSince(userId, since)); + activity.addAll(outreach.findContactedAtSince(userId, since)); + return activity; + } +} diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/ApplicationStatusEventJpaRepository.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/ApplicationStatusEventJpaRepository.java index a2db3b13..64f69e3d 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/ApplicationStatusEventJpaRepository.java +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/ApplicationStatusEventJpaRepository.java @@ -1,13 +1,17 @@ package com.autoapplicant.adapter.persistence.repository; import com.autoapplicant.adapter.persistence.entity.ApplicationStatusEventEntity; -import org.springframework.data.jpa.repository.JpaRepository; - +import java.time.Instant; import java.util.List; import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface ApplicationStatusEventJpaRepository extends JpaRepository { List findByUserIdOrderByOccurredAtAsc(UUID userId); List findByApplicationIdAndUserIdOrderByOccurredAtAsc(UUID applicationId, UUID userId); + + @Query("SELECT e.occurredAt FROM ApplicationStatusEventEntity e WHERE e.userId = :userId AND e.occurredAt >= :since") + List findOccurredAtSince(UUID userId, Instant since); } diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/GeneratedDocumentJpaRepository.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/GeneratedDocumentJpaRepository.java index 5279c40d..a78d9847 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/GeneratedDocumentJpaRepository.java +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/GeneratedDocumentJpaRepository.java @@ -1,6 +1,7 @@ package com.autoapplicant.adapter.persistence.repository; import com.autoapplicant.adapter.persistence.entity.GeneratedDocumentEntity; +import java.time.Instant; import java.util.List; import java.util.UUID; import org.springframework.data.domain.Pageable; @@ -15,4 +16,7 @@ public interface GeneratedDocumentJpaRepository extends JpaRepository findRecentByUserIdAndType(UUID userId, String documentType, Pageable pageable); + + @Query("SELECT e.createdAt FROM GeneratedDocumentEntity e WHERE e.userId = :userId AND e.createdAt >= :since") + List findCreatedAtSince(UUID userId, Instant since); } diff --git a/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/OutreachContactJpaRepository.java b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/OutreachContactJpaRepository.java index 7a242446..8f3f9699 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/OutreachContactJpaRepository.java +++ b/backend/src/main/java/com/autoapplicant/adapter/persistence/repository/OutreachContactJpaRepository.java @@ -1,13 +1,20 @@ package com.autoapplicant.adapter.persistence.repository; import com.autoapplicant.adapter.persistence.entity.OutreachContactEntity; -import org.springframework.data.jpa.repository.JpaRepository; - +import java.time.Instant; 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.Query; public interface OutreachContactJpaRepository extends JpaRepository { List findByUserId(UUID userId); Optional findByUserIdAndCompanyId(UUID userId, UUID companyId); + + @Query("SELECT e.createdAt FROM OutreachContactEntity e WHERE e.userId = :userId AND e.createdAt >= :since") + List findCreatedAtSince(UUID userId, Instant since); + + @Query("SELECT e.contactedAt FROM OutreachContactEntity e WHERE e.userId = :userId AND e.contactedAt >= :since") + List findContactedAtSince(UUID userId, Instant since); } diff --git a/backend/src/main/java/com/autoapplicant/adapter/web/controller/AnalyticsController.java b/backend/src/main/java/com/autoapplicant/adapter/web/controller/AnalyticsController.java index 3466cb07..08a2cc16 100644 --- a/backend/src/main/java/com/autoapplicant/adapter/web/controller/AnalyticsController.java +++ b/backend/src/main/java/com/autoapplicant/adapter/web/controller/AnalyticsController.java @@ -1,16 +1,21 @@ package com.autoapplicant.adapter.web.controller; import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.analytics.ActivityStreak; import com.autoapplicant.domain.analytics.ApplicationMetrics; import com.autoapplicant.domain.analytics.DetailedMetrics; import com.autoapplicant.domain.analytics.FunnelVelocity; import com.autoapplicant.domain.analytics.WeeklyTrend; +import com.autoapplicant.port.in.analytics.GetActivityStreakUseCase; import com.autoapplicant.port.in.analytics.GetApplicationMetricsUseCase; import com.autoapplicant.port.in.analytics.GetDetailedMetricsUseCase; import com.autoapplicant.port.in.analytics.GetFunnelVelocityUseCase; import com.autoapplicant.port.in.analytics.GetWeeklyTrendUseCase; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.DateTimeException; +import java.time.ZoneId; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -23,17 +28,20 @@ public class AnalyticsController { private final GetDetailedMetricsUseCase getDetailedMetrics; private final GetWeeklyTrendUseCase getWeeklyTrend; private final GetFunnelVelocityUseCase getFunnelVelocity; + private final GetActivityStreakUseCase getActivityStreak; private final SecurityContextHelper secCtx; public AnalyticsController(GetApplicationMetricsUseCase getMetrics, GetDetailedMetricsUseCase getDetailedMetrics, GetWeeklyTrendUseCase getWeeklyTrend, GetFunnelVelocityUseCase getFunnelVelocity, + GetActivityStreakUseCase getActivityStreak, SecurityContextHelper secCtx) { this.getMetrics = getMetrics; this.getDetailedMetrics = getDetailedMetrics; this.getWeeklyTrend = getWeeklyTrend; this.getFunnelVelocity = getFunnelVelocity; + this.getActivityStreak = getActivityStreak; this.secCtx = secCtx; } @@ -60,4 +68,19 @@ public ResponseEntity weeklyTrend() { public ResponseEntity funnelVelocity() { return ResponseEntity.ok(getFunnelVelocity.getFunnelVelocity(secCtx.getCurrentUserId())); } + + @Operation(summary = "Consecutive days with job-search activity, counted in the caller's time zone") + @ApiResponse(responseCode = "400", description = "Unknown time zone") + @GetMapping("/streak") + public ResponseEntity streak(@RequestParam(defaultValue = "UTC") String zone) { + return ResponseEntity.ok(getActivityStreak.getActivityStreak(secCtx.getCurrentUserId(), parseZone(zone))); + } + + private static ZoneId parseZone(String zone) { + try { + return ZoneId.of(zone); + } catch (DateTimeException e) { + throw new IllegalArgumentException("Unknown time zone: " + zone, e); + } + } } diff --git a/backend/src/main/java/com/autoapplicant/domain/analytics/ActivityStreak.java b/backend/src/main/java/com/autoapplicant/domain/analytics/ActivityStreak.java new file mode 100644 index 00000000..a4ab47ac --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/domain/analytics/ActivityStreak.java @@ -0,0 +1,46 @@ +package com.autoapplicant.domain.analytics; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * Consecutive calendar days, in the user's own time zone, with at least one bit of job-search + * activity. A day with nothing yet doesn't break the run until it's over, so a streak that + * ended yesterday still counts as ongoing today. {@code lastWeek} is the activity count per + * day for the seven days ending today, oldest first. + */ +public record ActivityStreak(int days, List lastWeek) { + + static final int WEEK = 7; + + public static ActivityStreak of(Collection activity, ZoneId zone, LocalDate today) { + Map perDay = activity.stream() + .collect(Collectors.groupingBy(at -> LocalDate.ofInstant(at, zone), Collectors.counting())); + return new ActivityStreak(runEndingAt(perDay, today), lastWeek(perDay, today)); + } + + private static int runEndingAt(Map perDay, LocalDate today) { + LocalDate day = perDay.containsKey(today) ? today : today.minusDays(1); + int days = 0; + while (perDay.containsKey(day)) { + days++; + day = day.minusDays(1); + } + return days; + } + + private static List lastWeek(Map perDay, LocalDate today) { + Function count = day -> perDay.getOrDefault(day, 0L).intValue(); + return IntStream.range(0, WEEK) + .mapToObj(i -> today.minusDays(WEEK - 1L - i)) + .map(count) + .toList(); + } +} diff --git a/backend/src/main/java/com/autoapplicant/port/in/analytics/GetActivityStreakUseCase.java b/backend/src/main/java/com/autoapplicant/port/in/analytics/GetActivityStreakUseCase.java new file mode 100644 index 00000000..3e2dc903 --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/port/in/analytics/GetActivityStreakUseCase.java @@ -0,0 +1,9 @@ +package com.autoapplicant.port.in.analytics; + +import com.autoapplicant.domain.analytics.ActivityStreak; +import java.time.ZoneId; +import java.util.UUID; + +public interface GetActivityStreakUseCase { + ActivityStreak getActivityStreak(UUID userId, ZoneId zone); +} diff --git a/backend/src/main/java/com/autoapplicant/port/out/analytics/JobSearchActivityPort.java b/backend/src/main/java/com/autoapplicant/port/out/analytics/JobSearchActivityPort.java new file mode 100644 index 00000000..cd689538 --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/port/out/analytics/JobSearchActivityPort.java @@ -0,0 +1,10 @@ +package com.autoapplicant.port.out.analytics; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** When the user did something for their job search: application moves, generated documents, outreach. */ +public interface JobSearchActivityPort { + List findActivitySince(UUID userId, Instant since); +} diff --git a/backend/src/main/java/com/autoapplicant/usecase/analytics/ActivityStreakService.java b/backend/src/main/java/com/autoapplicant/usecase/analytics/ActivityStreakService.java new file mode 100644 index 00000000..4166017f --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/usecase/analytics/ActivityStreakService.java @@ -0,0 +1,35 @@ +package com.autoapplicant.usecase.analytics; + +import com.autoapplicant.domain.analytics.ActivityStreak; +import com.autoapplicant.port.in.analytics.GetActivityStreakUseCase; +import com.autoapplicant.port.out.analytics.JobSearchActivityPort; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; + +@Service +public class ActivityStreakService implements GetActivityStreakUseCase { + + /** How far back to look. Streaks longer than this are reported as this long. */ + static final int LOOKBACK_DAYS = 400; + + private final JobSearchActivityPort activity; + private final Clock clock; + + public ActivityStreakService(JobSearchActivityPort activity, Clock clock) { + this.activity = activity; + this.clock = clock; + } + + @Override + public ActivityStreak getActivityStreak(UUID userId, ZoneId zone) { + LocalDate today = LocalDate.ofInstant(clock.instant(), zone); + Instant since = today.minusDays(LOOKBACK_DAYS).atStartOfDay(zone).toInstant(); + List events = activity.findActivitySince(userId, since); + return ActivityStreak.of(events, zone, today); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/AnalyticsControllerStreakTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/AnalyticsControllerStreakTest.java new file mode 100644 index 00000000..d39e7ea0 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/AnalyticsControllerStreakTest.java @@ -0,0 +1,83 @@ +package com.autoapplicant.adapter.web.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.analytics.ActivityStreak; +import com.autoapplicant.port.in.analytics.GetActivityStreakUseCase; +import com.autoapplicant.port.in.analytics.GetApplicationMetricsUseCase; +import com.autoapplicant.port.in.analytics.GetDetailedMetricsUseCase; +import com.autoapplicant.port.in.analytics.GetFunnelVelocityUseCase; +import com.autoapplicant.port.in.analytics.GetWeeklyTrendUseCase; +import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; +import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; +import com.google.firebase.auth.FirebaseAuth; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AnalyticsController.class) +@AutoConfigureMockMvc(addFilters = false) +class AnalyticsControllerStreakTest { + + @Autowired MockMvc mvc; + + @MockitoBean GetActivityStreakUseCase getActivityStreak; + @MockitoBean GetApplicationMetricsUseCase getMetrics; + @MockitoBean GetDetailedMetricsUseCase getDetailedMetrics; + @MockitoBean GetWeeklyTrendUseCase getWeeklyTrend; + @MockitoBean GetFunnelVelocityUseCase getFunnelVelocity; + @MockitoBean SecurityContextHelper secCtx; + @MockitoBean ProvisionFirebaseUserUseCase provisionUser; + @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser; + @MockitoBean FirebaseAuth firebaseAuth; + + UUID userId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + when(secCtx.getCurrentUserId()).thenReturn(userId); + } + + @Test + void counts_in_the_requested_zone() throws Exception { + when(getActivityStreak.getActivityStreak(userId, ZoneId.of("Europe/Copenhagen"))) + .thenReturn(new ActivityStreak(4, List.of(0, 0, 0, 1, 1, 2, 1))); + + mvc.perform(get("/api/v1/analytics/streak").param("zone", "Europe/Copenhagen")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.days").value(4)) + .andExpect(jsonPath("$.lastWeek[5]").value(2)); + } + + @Test + void defaults_to_utc_without_a_zone() throws Exception { + when(getActivityStreak.getActivityStreak(userId, ZoneId.of("UTC"))) + .thenReturn(new ActivityStreak(0, List.of(0, 0, 0, 0, 0, 0, 0))); + + mvc.perform(get("/api/v1/analytics/streak")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.days").value(0)); + } + + @Test + void rejects_an_unknown_zone() throws Exception { + mvc.perform(get("/api/v1/analytics/streak").param("zone", "Mars/Olympus_Mons")) + .andExpect(status().isBadRequest()); + + verify(getActivityStreak, never()).getActivityStreak(any(), any()); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java index bcb19942..f8fdbdbd 100644 --- a/backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java @@ -93,7 +93,7 @@ void get_by_id_returns_404_when_missing() throws Exception { @Test void get_research_returns_empty_notes_when_none_saved() throws Exception { - when(research.getResearch(companyId)).thenReturn(Optional.empty()); + when(research.getResearch(userId, companyId)).thenReturn(Optional.empty()); mvc.perform(get("/api/v1/companies/{id}/research", companyId)) .andExpect(status().isOk()) @@ -102,7 +102,7 @@ void get_research_returns_empty_notes_when_none_saved() throws Exception { @Test void save_research_returns_saved_notes() throws Exception { - when(research.saveResearch(companyId, "They ship weekly")) + when(research.saveResearch(userId, companyId, "They ship weekly")) .thenReturn(Optional.of(new CompanyResearch("They ship weekly", Instant.now()))); mvc.perform(put("/api/v1/companies/{id}/research", companyId) @@ -116,7 +116,7 @@ void save_research_returns_saved_notes() throws Exception { @Test void save_research_returns_404_for_unknown_company() throws Exception { - when(research.saveResearch(eq(companyId), any())).thenReturn(Optional.empty()); + when(research.saveResearch(eq(userId), eq(companyId), any())).thenReturn(Optional.empty()); mvc.perform(put("/api/v1/companies/{id}/research", companyId) .contentType(MediaType.APPLICATION_JSON) diff --git a/backend/src/test/java/com/autoapplicant/domain/analytics/ActivityStreakTest.java b/backend/src/test/java/com/autoapplicant/domain/analytics/ActivityStreakTest.java new file mode 100644 index 00000000..ff12c900 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/domain/analytics/ActivityStreakTest.java @@ -0,0 +1,109 @@ +package com.autoapplicant.domain.analytics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ActivityStreakTest { + + private static final ZoneId CPH = ZoneId.of("Europe/Copenhagen"); + private static final LocalDate TODAY = LocalDate.of(2026, 10, 1); + + @Test + void no_activity_is_no_streak() { + ActivityStreak none = ActivityStreak.of(List.of(), CPH, TODAY); + + assertThat(none.days()).isZero(); + assertThat(none.lastWeek()).containsExactly(0, 0, 0, 0, 0, 0, 0); + } + + @Test + void activity_only_today_is_one_day() { + assertThat(streak(at(TODAY, 9))).isEqualTo(1); + } + + @Test + void several_events_on_one_day_count_once() { + assertThat(streak(at(TODAY, 9), at(TODAY, 12), at(TODAY, 18))).isEqualTo(1); + } + + @Test + void counts_consecutive_days_up_to_today() { + assertThat(streak(at(TODAY, 9), at(TODAY.minusDays(1), 9), at(TODAY.minusDays(2), 9))).isEqualTo(3); + } + + @Test + void quiet_today_keeps_yesterdays_streak_going() { + assertThat(streak(at(TODAY.minusDays(1), 20), at(TODAY.minusDays(2), 8))).isEqualTo(2); + } + + @Test + void streak_that_ended_two_days_ago_is_over() { + assertThat(streak(at(TODAY.minusDays(2), 9), at(TODAY.minusDays(3), 9))).isZero(); + } + + @Test + void a_gap_ends_the_streak() { + assertThat(streak(at(TODAY, 9), at(TODAY.minusDays(1), 9), at(TODAY.minusDays(3), 9), at(TODAY.minusDays(4), 9))) + .isEqualTo(2); + } + + @Test + void future_activity_is_ignored() { + assertThat(streak(at(TODAY.plusDays(1), 9))).isZero(); + } + + @Test + void days_follow_the_users_zone_not_utc() { + // 22:30 UTC on 30 Sep is already 1 Oct in Copenhagen, but still 30 Sep in New York. + Instant lateEvening = Instant.parse("2026-09-30T22:30:00Z"); + + assertThat(ActivityStreak.of(List.of(lateEvening), CPH, TODAY).days()).isEqualTo(1); + assertThat(ActivityStreak.of(List.of(lateEvening), ZoneId.of("America/New_York"), TODAY).days()) + .isEqualTo(1); + assertThat(ActivityStreak.of(List.of(lateEvening), ZoneId.of("America/New_York"), TODAY.plusDays(1)).days()) + .isZero(); + } + + @Test + void just_before_and_after_local_midnight_are_two_days() { + Instant beforeMidnight = LocalDateTime.of(2026, 9, 30, 23, 59).atZone(CPH).toInstant(); + Instant afterMidnight = LocalDateTime.of(2026, 10, 1, 0, 1).atZone(CPH).toInstant(); + + assertThat(ActivityStreak.of(List.of(beforeMidnight, afterMidnight), CPH, TODAY).days()).isEqualTo(2); + } + + @Test + void survives_a_daylight_saving_change() { + // Copenhagen springs forward on 29 Mar 2026, so that day is only 23 hours long. + LocalDate dstDay = LocalDate.of(2026, 3, 29); + Instant before = LocalDateTime.of(2026, 3, 28, 23, 30).atZone(CPH).toInstant(); + Instant during = LocalDateTime.of(2026, 3, 29, 0, 30).atZone(CPH).toInstant(); + Instant after = LocalDateTime.of(2026, 3, 30, 0, 10).atZone(CPH).toInstant(); + + assertThat(ActivityStreak.of(List.of(before, during, after), CPH, dstDay.plusDays(1)).days()).isEqualTo(3); + } + + @Test + void counts_the_last_seven_days_oldest_first() { + ActivityStreak streak = ActivityStreak.of( + List.of(at(TODAY, 9), at(TODAY, 15), at(TODAY.minusDays(2), 9), at(TODAY.minusDays(6), 9), + at(TODAY.minusDays(7), 9)), + CPH, TODAY); + + assertThat(streak.lastWeek()).containsExactly(1, 0, 0, 0, 1, 0, 2); + } + + private static int streak(Instant... activity) { + return ActivityStreak.of(List.of(activity), CPH, TODAY).days(); + } + + private static Instant at(LocalDate day, int hour) { + return day.atTime(hour, 0).atZone(CPH).toInstant(); + } +} diff --git a/backend/src/test/java/com/autoapplicant/usecase/analytics/ActivityStreakServiceTest.java b/backend/src/test/java/com/autoapplicant/usecase/analytics/ActivityStreakServiceTest.java new file mode 100644 index 00000000..3cecb8b4 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/usecase/analytics/ActivityStreakServiceTest.java @@ -0,0 +1,68 @@ +package com.autoapplicant.usecase.analytics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.autoapplicant.port.out.analytics.JobSearchActivityPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ActivityStreakServiceTest { + + private static final UUID USER = UUID.randomUUID(); + private static final ZoneId CPH = ZoneId.of("Europe/Copenhagen"); + + private final FakeActivity activity = new FakeActivity(); + + @Test + void today_and_the_day_boundaries_follow_the_users_zone() { + Clock clock = Clock.fixed(Instant.parse("2026-09-30T23:30:00Z"), ZoneOffset.UTC); + activity.events.add(Instant.parse("2026-09-29T10:00:00Z")); + activity.events.add(Instant.parse("2026-09-30T23:00:00Z")); + + // Copenhagen: the second event is 1 Oct 01:00, so 30 Sep is a gap. + assertThat(service(clock).getActivityStreak(USER, CPH).days()).isEqualTo(1); + // UTC: both events are on consecutive days and today is 30 Sep. + assertThat(service(clock).getActivityStreak(USER, ZoneOffset.UTC).days()).isEqualTo(2); + } + + @Test + void looks_back_from_local_midnight_at_the_start_of_the_window() { + Clock clock = Clock.fixed(Instant.parse("2026-10-01T12:00:00Z"), ZoneOffset.UTC); + + service(clock).getActivityStreak(USER, CPH); + + assertThat(activity.askedFor).isEqualTo(USER); + // 400 days before 1 Oct 2026 is 27 Aug 2025, which starts at 22:00 UTC the day before (CEST). + assertThat(activity.askedSince).isEqualTo(Instant.parse("2025-08-26T22:00:00Z")); + } + + @Test + void no_activity_is_zero() { + Clock clock = Clock.fixed(Instant.parse("2026-10-01T12:00:00Z"), ZoneOffset.UTC); + + assertThat(service(clock).getActivityStreak(USER, CPH).days()).isZero(); + } + + private ActivityStreakService service(Clock clock) { + return new ActivityStreakService(activity, clock); + } + + private static final class FakeActivity implements JobSearchActivityPort { + final List events = new ArrayList<>(); + UUID askedFor; + Instant askedSince; + + @Override + public List findActivitySince(UUID userId, Instant since) { + askedFor = userId; + askedSince = since; + return events; + } + } +} diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 457452da..4cfdb7bc 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -114,11 +114,7 @@
- -
- - {{ 'nav.streak' | translate:{ count: 12 } }} -
+