Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 103 additions & 2 deletions src/Orbit.Infrastructure/Services/UserStreakService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Orbit.Application.Common;
using Orbit.Application.Habits.Services;
using Orbit.Application.Social.Services;
Expand All @@ -7,13 +9,16 @@

namespace Orbit.Infrastructure.Services;

public class UserStreakService(
public partial class UserStreakService(

Check warning on line 12 in src/Orbit.Infrastructure/Services/UserStreakService.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Constructor has 9 parameters, which is greater than the 7 authorized.
IGenericRepository<User> userRepository,
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IGenericRepository<StreakFreeze> streakFreezeRepository,
IUserDateService userDateService,
IFriendFeedEventEmitter friendFeedEventEmitter) : IUserStreakService
IFriendFeedEventEmitter friendFeedEventEmitter,
IUnitOfWork unitOfWork,
IFeatureFlagService featureFlagService,
ILogger<UserStreakService> logger) : IUserStreakService

Check warning on line 21 in src/Orbit.Infrastructure/Services/UserStreakService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Constructor has 9 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ9abcl44Rh9wRo8k1Cu&open=AZ9abcl44Rh9wRo8k1Cu&pullRequest=383
{
public async Task<UserStreakState?> RecalculateAsync(
Guid userId,
Expand Down Expand Up @@ -48,6 +53,14 @@
var (currentStreak, lastActiveDate) = HabitScheduleService.ComputeStreakAsOf(
expectedDates, completionDateSet, freezeDateSet, lookbackStart, userToday);

if (await TryBridgeRecentGapWithBankedFreezeAsync(
user, userToday, lookbackStart, expectedDates, completionDateSet, freezeDateSet,
currentStreak, cancellationToken))
{
(currentStreak, lastActiveDate) = HabitScheduleService.ComputeStreakAsOf(
expectedDates, completionDateSet, freezeDateSet, lookbackStart, userToday);
}

var longestStreak = ComputeLongestStreak(expectedDates, completionDateSet, freezeDateSet);
if (currentStreak > longestStreak) longestStreak = currentStreak;

Expand Down Expand Up @@ -93,6 +106,94 @@
return (completionDateSet, freezeDateSet, contributingHabits);
}

/// <summary>
/// Applies one banked streak freeze to bridge the user's most recent scheduled miss (their local
/// "yesterday") during recalculation — the same action the hourly <see cref="StreakFreezeAutoActivationService"/>
/// takes — so the streak is preserved regardless of which path runs first. The consume + the
/// <see cref="StreakFreeze"/> insert are flushed in one guarded save so they commit atomically; a persisted row
/// makes the operation idempotent, since a later recalculation sees the frozen date in
/// <paramref name="freezeDateSet"/> and neither re-consumes a freeze nor inflates the streak. The hourly job
/// can insert the same <c>(UserId, UsedOnDate)</c> row concurrently: on that unique-violation the save rolls
/// back this consume (so exactly one freeze is spent overall), the staged rows are dropped, and the day is
/// still treated as covered because the winner's row already bridges it. Only spends a freeze when covering the
/// day actually raises the streak (so an over-large gap is left to break) and the user is freeze-eligible and
/// under the monthly cap. Returns true, extending <paramref name="freezeDateSet"/> with the covered date,
/// whenever the day ends up covered by this call or its concurrent winner.
/// </summary>
private async Task<bool> TryBridgeRecentGapWithBankedFreezeAsync(

Check warning on line 123 in src/Orbit.Infrastructure/Services/UserStreakService.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Method has 8 parameters, which is greater than the 7 authorized.
User user,
DateOnly userToday,
DateOnly lookbackStart,
HashSet<DateOnly> expectedDates,
HashSet<DateOnly> completionDateSet,
HashSet<DateOnly> freezeDateSet,
int currentStreak,
CancellationToken cancellationToken)

Check warning on line 131 in src/Orbit.Infrastructure/Services/UserStreakService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 8 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ9abcl44Rh9wRo8k1Cv&open=AZ9abcl44Rh9wRo8k1Cv&pullRequest=383
{
if (user.StreakFreezesAccumulated <= 0)
return false;

var missedDate = userToday.AddDays(-1);
if (!expectedDates.Contains(missedDate)
|| completionDateSet.Contains(missedDate)
|| freezeDateSet.Contains(missedDate))
{
return false;
}

var monthStart = new DateOnly(missedDate.Year, missedDate.Month, 1);
var monthEnd = monthStart.AddMonths(1);
var freezesThisMonth = freezeDateSet.Count(date => date >= monthStart && date < monthEnd);
if (freezesThisMonth >= AppConstants.MaxStreakFreezesPerMonth)
return false;

var bridged = new HashSet<DateOnly>(freezeDateSet) { missedDate };
var (streakWithBridge, _) = HabitScheduleService.ComputeStreakAsOf(
expectedDates, completionDateSet, bridged, lookbackStart, userToday);
if (streakWithBridge <= currentStreak)
return false;

var enabledFlags = await featureFlagService.GetEnabledKeysForUserAsync(user.Id, cancellationToken);
if (!user.HasProAccess && !enabledFlags.Contains(FeatureFlagKeys.GamificationFreeTier))
return false;

if (user.ConsumeStreakFreeze().IsFailure)
return false;

await streakFreezeRepository.AddAsync(StreakFreeze.Create(user.Id, missedDate), cancellationToken);
Comment thread
thomasluizon marked this conversation as resolved.

try
{
await unitOfWork.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (DbUniqueViolation.IsUniqueViolation(ex))
{
unitOfWork.DiscardChanges();
freezeDateSet.Add(missedDate);
if (logger.IsEnabled(LogLevel.Debug))
LogBankedFreezeAlreadyCovered(logger, user.Id, missedDate);
return true;
}

freezeDateSet.Add(missedDate);
if (logger.IsEnabled(LogLevel.Information))
LogBankedFreezeApplied(logger, user.Id, missedDate);

return true;
}

[LoggerMessage(
EventId = 1,
Level = LogLevel.Information,
Message = "Applied banked streak freeze for user {UserId} on {FrozenDate} during recalculation to bridge a missed scheduled day")]
private static partial void LogBankedFreezeApplied(ILogger logger, Guid userId, DateOnly frozenDate);

[LoggerMessage(
EventId = 2,
Level = LogLevel.Debug,
Message = "Banked streak freeze for user {UserId} on {FrozenDate} was already inserted by a concurrent activation; treating the day as covered")]
private static partial void LogBankedFreezeAlreadyCovered(ILogger logger, Guid userId, DateOnly frozenDate);

private static int ComputeLongestStreak(
HashSet<DateOnly> expectedDates,
HashSet<DateOnly> completionDateSet,
Expand All @@ -118,7 +219,7 @@
return longest;
}

private static UserStreakState CalendarFallback(

Check warning on line 222 in src/Orbit.Infrastructure/Services/UserStreakService.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
User user,
HashSet<DateOnly> completionDateSet,
HashSet<DateOnly> freezeDateSet,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Orbit.Application.Gamification.Queries;
using Orbit.Application.Social.Services;
Expand Down Expand Up @@ -264,6 +265,10 @@ private static async Task<int> ComputeCanonicalCurrentStreakAsync(User user, par
var freezeRepo = Substitute.For<IGenericRepository<StreakFreeze>>();
var userDateService = Substitute.For<IUserDateService>();
var feedEmitter = Substitute.For<IFriendFeedEventEmitter>();
var unitOfWork = Substitute.For<IUnitOfWork>();
var featureFlagService = Substitute.For<IFeatureFlagService>();
featureFlagService.GetEnabledKeysForUserAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(new List<string>());

userRepo.FindOneTrackedAsync(
Arg.Any<Expression<Func<User, bool>>>(),
Expand All @@ -279,7 +284,8 @@ private static async Task<int> ComputeCanonicalCurrentStreakAsync(User user, par
.Returns(new List<StreakFreeze>());

var service = new UserStreakService(
userRepo, habitRepo, habitLogRepo, freezeRepo, userDateService, feedEmitter);
userRepo, habitRepo, habitLogRepo, freezeRepo, userDateService, feedEmitter,
unitOfWork, featureFlagService, NullLogger<UserStreakService>.Instance);
var state = await service.RecalculateAsync(UserId, CancellationToken.None);
return state!.CurrentStreak;
}
Expand Down
Loading
Loading