diff --git a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs index e3aaa4e2..8248b781 100644 --- a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs +++ b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs @@ -26,7 +26,9 @@ public record GetStreakInfoQuery(Guid UserId) : IRequest userRepository, IGenericRepository streakFreezeRepository, - IUserDateService userDateService) : IRequestHandler> + IUserDateService userDateService, + IUserStreakService userStreakService, + IUnitOfWork unitOfWork) : IRequestHandler> { public async Task> Handle(GetStreakInfoQuery request, CancellationToken cancellationToken) { @@ -37,6 +39,15 @@ public async Task> Handle(GetStreakInfoQuery request, if (!user.HasProAccess) return Result.PayGateFailure("Streak insights are a Pro feature. Upgrade to unlock!"); + var recalculatedStreak = await userStreakService.RecalculateAsync( + request.UserId, cancellationToken, awardFreezeIfEligible: false); + if (recalculatedStreak is not null) + await unitOfWork.SaveChangesAsync(cancellationToken); + + var currentStreak = recalculatedStreak?.CurrentStreak ?? user.CurrentStreak; + var longestStreak = recalculatedStreak?.LongestStreak ?? user.LongestStreak; + var lastActiveDate = recalculatedStreak?.LastActiveDate ?? user.LastActiveDate; + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); var monthStart = new DateOnly(today.Year, today.Month, 1); @@ -56,11 +67,11 @@ public async Task> Handle(GetStreakInfoQuery request, var freezesAvailableToUse = Math.Min(user.StreakFreezesAccumulated, remainingMonthlyQuota); - var daysSinceLastAward = Math.Max(0, user.CurrentStreak - user.LastFreezeAwardStreak); - var daysUntilNextFreeze = user.CurrentStreak <= 0 + var daysSinceLastAward = Math.Max(0, currentStreak - user.LastFreezeAwardStreak); + var daysUntilNextFreeze = currentStreak <= 0 ? AppConstants.StreakDaysPerFreeze : Math.Max(0, AppConstants.StreakDaysPerFreeze - (daysSinceLastAward % AppConstants.StreakDaysPerFreeze)); - if (daysUntilNextFreeze == 0 && user.CurrentStreak > 0 && user.StreakFreezesAccumulated >= AppConstants.MaxStreakFreezesAccumulated) + if (daysUntilNextFreeze == 0 && currentStreak > 0 && user.StreakFreezesAccumulated >= AppConstants.MaxStreakFreezesAccumulated) { daysUntilNextFreeze = AppConstants.StreakDaysPerFreeze; } @@ -73,9 +84,9 @@ public async Task> Handle(GetStreakInfoQuery request, .ToList(); return Result.Success(new StreakInfoResponse( - user.CurrentStreak, - user.LongestStreak, - user.LastActiveDate, + currentStreak, + longestStreak, + lastActiveDate, freezesUsedThisMonth, user.StreakFreezesAccumulated, AppConstants.MaxStreakFreezesPerMonth, diff --git a/src/Orbit.Infrastructure/Services/AiSummaryService.cs b/src/Orbit.Infrastructure/Services/AiSummaryService.cs index 8ef9163f..69284e0a 100644 --- a/src/Orbit.Infrastructure/Services/AiSummaryService.cs +++ b/src/Orbit.Infrastructure/Services/AiSummaryService.cs @@ -12,7 +12,7 @@ public sealed partial class AiSummaryService( AiCompletionClient aiClient, ILogger logger) : ISummaryService { - private const int MaxSummaryChars = 140; + private const int MaxSummaryChars = 300; public async Task> GenerateSummaryAsync( IEnumerable allHabits, @@ -37,7 +37,7 @@ public async Task> GenerateSummaryAsync( prompt, temperature: 0.7, cancellationToken, - maxOutputTokens: 120); + maxOutputTokens: 180); if (string.IsNullOrWhiteSpace(text)) return Result.Failure("AI returned empty response"); @@ -121,7 +121,7 @@ Write a short message to this person about their day. - Describe the ACTIVITY naturally, don't just parrot the exact habit title - BAD: "You have Yoga, Morning Routine, and Guitar Playing left." - GOOD: "Nice work getting your run in -- some guitar later could be a great way to unwind." - - Keep it to ONE short sentence -- two very short ones only when the day truly needs both -- under ~140 characters total, warm and close, like a friend who actually knows you -- never corporate or coach-like + - Keep it to TWO short sentences -- three only when the day truly needs them -- under ~300 characters total, warm and close, like a friend who actually knows you -- never corporate or coach-like - This message is shown for the WHOLE current part of the day, so it must read correctly whether they see it at the start or the end of that window - Treat the time of day as a broad window, not an exact moment; never imply a precise instant - Do NOT use phrases like "right now", "just woke up", "now that the afternoon is here", "as the day begins", "earlier today", or "upcoming later today" diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs index c2618940..01513352 100644 --- a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs @@ -3,6 +3,7 @@ using Orbit.Application.Gamification.Queries; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; using System.Linq.Expressions; namespace Orbit.Application.Tests.Queries.Gamification; @@ -12,6 +13,8 @@ public class GetStreakInfoQueryHandlerTests private readonly IGenericRepository _userRepo = Substitute.For>(); private readonly IGenericRepository _streakFreezeRepo = Substitute.For>(); private readonly IUserDateService _userDateService = Substitute.For(); + private readonly IUserStreakService _userStreakService = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly GetStreakInfoQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -19,7 +22,8 @@ public class GetStreakInfoQueryHandlerTests public GetStreakInfoQueryHandlerTests() { - _handler = new GetStreakInfoQueryHandler(_userRepo, _streakFreezeRepo, _userDateService); + _handler = new GetStreakInfoQueryHandler( + _userRepo, _streakFreezeRepo, _userDateService, _userStreakService, _unitOfWork); _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); } @@ -57,6 +61,31 @@ public async Task Handle_UserFound_ReturnsStreakInfo() result.Value.RecentFreezeDates.Should().BeEmpty(); } + [Fact] + public async Task Handle_RecalculatesStreakOnRead_AndReturnsFreshValues() + { + var user = CreateTestUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + _userStreakService.RecalculateAsync(UserId, Arg.Any(), awardFreezeIfEligible: false) + .Returns(new UserStreakState(11, 43, Today)); + + _streakFreezeRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List().AsReadOnly()); + + var query = new GetStreakInfoQuery(UserId); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.CurrentStreak.Should().Be(11); + result.Value.LongestStreak.Should().Be(43); + result.Value.LastActiveDate.Should().Be(Today); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + [Fact] public async Task Handle_UserNotFound_ReturnsFailure() {