From 4da5616bd8b109e1bb7866f7acfe0f0101ad4b0f Mon Sep 17 00:00:00 2001 From: CMaintz Date: Thu, 1 Oct 2026 11:33:33 +0200 Subject: [PATCH 1/5] feat(backend): activity streak endpoint counted in the user's time zone --- .../JobSearchActivityPersistenceAdapter.java | 36 +++++++ .../ApplicationStatusEventJpaRepository.java | 8 +- .../GeneratedDocumentJpaRepository.java | 9 +- .../OutreachContactJpaRepository.java | 11 ++- .../web/controller/AnalyticsController.java | 23 +++++ .../domain/analytics/ActivityStreak.java | 29 ++++++ .../analytics/GetActivityStreakUseCase.java | 9 ++ .../out/analytics/JobSearchActivityPort.java | 10 ++ .../analytics/ActivityStreakService.java | 35 +++++++ .../AnalyticsControllerStreakTest.java | 81 ++++++++++++++++ .../domain/analytics/ActivityStreakTest.java | 96 +++++++++++++++++++ .../analytics/ActivityStreakServiceTest.java | 68 +++++++++++++ 12 files changed, 408 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/autoapplicant/adapter/persistence/adapter/JobSearchActivityPersistenceAdapter.java create mode 100644 backend/src/main/java/com/autoapplicant/domain/analytics/ActivityStreak.java create mode 100644 backend/src/main/java/com/autoapplicant/port/in/analytics/GetActivityStreakUseCase.java create mode 100644 backend/src/main/java/com/autoapplicant/port/out/analytics/JobSearchActivityPort.java create mode 100644 backend/src/main/java/com/autoapplicant/usecase/analytics/ActivityStreakService.java create mode 100644 backend/src/test/java/com/autoapplicant/adapter/web/controller/AnalyticsControllerStreakTest.java create mode 100644 backend/src/test/java/com/autoapplicant/domain/analytics/ActivityStreakTest.java create mode 100644 backend/src/test/java/com/autoapplicant/usecase/analytics/ActivityStreakServiceTest.java 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 dea41f51..ea98778f 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,13 +1,13 @@ 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; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import java.util.List; -import java.util.UUID; - public interface GeneratedDocumentJpaRepository extends JpaRepository { List findByApplicationId(UUID applicationId); List findByUserIdOrderByCreatedAtDesc(UUID userId); @@ -16,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..4a01d20f --- /dev/null +++ b/backend/src/main/java/com/autoapplicant/domain/analytics/ActivityStreak.java @@ -0,0 +1,29 @@ +package com.autoapplicant.domain.analytics; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 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. + */ +public record ActivityStreak(int days) { + + public static ActivityStreak of(Collection activity, ZoneId zone, LocalDate today) { + Set activeDays = activity.stream() + .map(at -> LocalDate.ofInstant(at, zone)) + .collect(Collectors.toSet()); + LocalDate day = activeDays.contains(today) ? today : today.minusDays(1); + int days = 0; + while (activeDays.contains(day)) { + days++; + day = day.minusDays(1); + } + return new ActivityStreak(days); + } +} 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..3dbcacf2 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/AnalyticsControllerStreakTest.java @@ -0,0 +1,81 @@ +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.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)); + + mvc.perform(get("/api/v1/analytics/streak").param("zone", "Europe/Copenhagen")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.days").value(4)); + } + + @Test + void defaults_to_utc_without_a_zone() throws Exception { + when(getActivityStreak.getActivityStreak(userId, ZoneId.of("UTC"))) + .thenReturn(new ActivityStreak(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/domain/analytics/ActivityStreakTest.java b/backend/src/test/java/com/autoapplicant/domain/analytics/ActivityStreakTest.java new file mode 100644 index 00000000..6d23dfad --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/domain/analytics/ActivityStreakTest.java @@ -0,0 +1,96 @@ +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() { + assertThat(ActivityStreak.of(List.of(), CPH, TODAY).days()).isZero(); + } + + @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); + } + + 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; + } + } +} From 8ee71924106cf0a83cbf81557dd91969db938b8d Mon Sep 17 00:00:00 2001 From: CMaintz Date: Thu, 1 Oct 2026 11:33:34 +0200 Subject: [PATCH 2/5] feat(frontend): show the real activity streak in the sidebar and dashboard --- frontend/src/app/app.component.html | 6 +- frontend/src/app/app.component.ts | 12 +++- .../app/core/activity-streak.service.spec.ts | 69 +++++++++++++++++++ .../src/app/core/activity-streak.service.ts | 29 ++++++++ frontend/src/app/core/api/dashboard.api.ts | 10 +++ .../features/dashboard/dashboard.component.ts | 10 ++- .../streak-badge.component.spec.ts | 47 +++++++++++++ .../streak-badge/streak-badge.component.ts | 22 ++++++ .../app/shared/utils/application-insights.ts | 9 --- 9 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 frontend/src/app/core/activity-streak.service.spec.ts create mode 100644 frontend/src/app/core/activity-streak.service.ts create mode 100644 frontend/src/app/shared/components/streak-badge/streak-badge.component.spec.ts create mode 100644 frontend/src/app/shared/components/streak-badge/streak-badge.component.ts 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 } }} -
+