Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public static class AppConstants
public const int AdRewardBonusMessages = 5;
public const int AdRewardDailyCap = 3;
public const int MaxStreakFreezesPerMonth = 3;
public const int MaxStreakFreezesAccumulated = 3;
public const int StreakDaysPerFreeze = 7;
public const int MaxStreakLookbackDays = 365;
public static readonly string[] SupportedLanguages = ["en", "pt-BR"];
}
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public static class ErrorCodes
public const string ChatHistoryTooLarge = "CHAT_HISTORY_TOO_LARGE";
public const string MessageTooLong = "MESSAGE_TOO_LONG";
public const string StreakFreezeNotAvailable = "STREAK_FREEZE_NOT_AVAILABLE";
public const string StreakFreezeMonthlyLimit = "STREAK_FREEZE_MONTHLY_LIMIT";
public const string AlreadyUsedStreakFreezeToday = "ALREADY_USED_STREAK_FREEZE_TODAY";
public const string NoActiveStreak = "NO_ACTIVE_STREAK";
public const string NotEnoughCoins = "NOT_ENOUGH_COINS";
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/ErrorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public static class ErrorMessages
public const string ChatHistoryTooLarge = "Chat history too large.";
public const string MessageTooLong = "Message must be between 1 and 4000 characters.";
public const string StreakFreezeNotAvailable = "No streak freeze available.";
public const string StreakFreezeMonthlyLimit = "You've used all streak freezes this month.";
public const string AlreadyUsedStreakFreezeToday = "Streak freeze already used today.";
public const string NoActiveStreak = "No active streak to protect.";
public const string NotEnoughCoins = "Not enough coins.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ namespace Orbit.Application.Gamification.Commands;
public record StreakFreezeResponse(
int FreezesRemainingThisMonth,
DateOnly FrozenDate,
int CurrentStreak);
int CurrentStreak,
int StreakFreezesAccumulated);

public record ActivateStreakFreezeCommand(Guid UserId) : IRequest<Result<StreakFreezeResponse>>;

Expand Down Expand Up @@ -38,11 +39,12 @@ public async Task<Result<StreakFreezeResponse>> Handle(ActivateStreakFreezeComma

var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);

// Validate streak > 0
if (existingStreak.CurrentStreak <= 0)
return Result.Failure<StreakFreezeResponse>(ErrorMessages.NoActiveStreak, ErrorCodes.NoActiveStreak);

// Check if user already logged a habit today
if (user.StreakFreezesAccumulated <= 0)
return Result.Failure<StreakFreezeResponse>(ErrorMessages.StreakFreezeNotAvailable, ErrorCodes.StreakFreezeNotAvailable);

var userHabits = await habitRepository.FindAsync(h => h.UserId == request.UserId, cancellationToken);
var habitIds = userHabits.Select(h => h.Id).ToList();

Expand All @@ -56,36 +58,39 @@ public async Task<Result<StreakFreezeResponse>> Handle(ActivateStreakFreezeComma
return Result.Failure<StreakFreezeResponse>("You already completed a habit today. No freeze needed!");
}

// Check if already frozen today
var existingFreeze = await streakFreezeRepository.FindAsync(
sf => sf.UserId == request.UserId && sf.UsedOnDate == today,
cancellationToken);

if (existingFreeze.Count > 0)
return Result.Failure<StreakFreezeResponse>(ErrorMessages.AlreadyUsedStreakFreezeToday, ErrorCodes.AlreadyUsedStreakFreezeToday);

// Count freezes in rolling 30-day window
var windowStart = today.AddDays(-29);
var recentFreezes = await streakFreezeRepository.FindAsync(
sf => sf.UserId == request.UserId && sf.UsedOnDate >= windowStart,
var monthStart = new DateOnly(today.Year, today.Month, 1);
var monthEnd = monthStart.AddMonths(1);
var freezesThisMonth = await streakFreezeRepository.FindAsync(
sf => sf.UserId == request.UserId && sf.UsedOnDate >= monthStart && sf.UsedOnDate < monthEnd,
cancellationToken);

if (recentFreezes.Count >= AppConstants.MaxStreakFreezesPerMonth)
return Result.Failure<StreakFreezeResponse>(ErrorMessages.StreakFreezeNotAvailable, ErrorCodes.StreakFreezeNotAvailable);
if (freezesThisMonth.Count >= AppConstants.MaxStreakFreezesPerMonth)
return Result.Failure<StreakFreezeResponse>(ErrorMessages.StreakFreezeMonthlyLimit, ErrorCodes.StreakFreezeMonthlyLimit);

var consume = user.ConsumeStreakFreeze();
if (consume.IsFailure)
return Result.Failure<StreakFreezeResponse>(consume.Error!, ErrorCodes.StreakFreezeNotAvailable);

// Create freeze
var freeze = StreakFreeze.Create(request.UserId, today);
await streakFreezeRepository.AddAsync(freeze, cancellationToken);

await unitOfWork.SaveChangesAsync(cancellationToken);
var updatedStreak = await userStreakService.RecalculateAsync(request.UserId, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

var freezesRemaining = AppConstants.MaxStreakFreezesPerMonth - (recentFreezes.Count + 1);
var freezesRemaining = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - (freezesThisMonth.Count + 1));

return Result.Success(new StreakFreezeResponse(
freezesRemaining,
today,
updatedStreak?.CurrentStreak ?? 0));
updatedStreak?.CurrentStreak ?? 0,
user.StreakFreezesAccumulated));
}
}
43 changes: 36 additions & 7 deletions src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ public record StreakInfoResponse(
int FreezesAvailable,
int MaxFreezesPerMonth,
bool IsFrozenToday,
IReadOnlyList<DateOnly> RecentFreezeDates);
IReadOnlyList<DateOnly> RecentFreezeDates,
int StreakFreezesAccumulated,
int MaxStreakFreezesAccumulated,
int DaysUntilNextFreeze,
int FreezesAvailableToUse,
bool CanEarnMore);

public record GetStreakInfoQuery(Guid UserId) : IRequest<Result<StreakInfoResponse>>;

Expand All @@ -31,15 +36,34 @@ public async Task<Result<StreakInfoResponse>> Handle(GetStreakInfoQuery request,

var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);

// Query recent freezes within the rolling 30-day window
var monthStart = new DateOnly(today.Year, today.Month, 1);
var monthEnd = monthStart.AddMonths(1);
var freezesThisMonth = await streakFreezeRepository.FindAsync(
sf => sf.UserId == request.UserId && sf.UsedOnDate >= monthStart && sf.UsedOnDate < monthEnd,
cancellationToken);

var windowStart = today.AddDays(-29);
var recentFreezes = await streakFreezeRepository.FindAsync(
sf => sf.UserId == request.UserId && sf.UsedOnDate >= windowStart,
cancellationToken);

var isFrozenToday = recentFreezes.Any(sf => sf.UsedOnDate == today);
var freezesUsed = recentFreezes.Count;
var freezesAvailable = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - freezesUsed);
var freezesUsedThisMonth = freezesThisMonth.Count;
var remainingMonthlyQuota = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - freezesUsedThisMonth);

var freezesAvailableToUse = Math.Min(user.StreakFreezesAccumulated, remainingMonthlyQuota);

var daysSinceLastAward = Math.Max(0, user.CurrentStreak - user.LastFreezeAwardStreak);
var daysUntilNextFreeze = user.CurrentStreak <= 0
? AppConstants.StreakDaysPerFreeze
: Math.Max(0, AppConstants.StreakDaysPerFreeze - (daysSinceLastAward % AppConstants.StreakDaysPerFreeze));
if (daysUntilNextFreeze == 0 && user.CurrentStreak > 0 && user.StreakFreezesAccumulated >= AppConstants.MaxStreakFreezesAccumulated)
{
daysUntilNextFreeze = AppConstants.StreakDaysPerFreeze;
}

var canEarnMore = user.StreakFreezesAccumulated < AppConstants.MaxStreakFreezesAccumulated;

var recentFreezeDates = recentFreezes
.Select(sf => sf.UsedOnDate)
.OrderByDescending(d => d)
Expand All @@ -49,10 +73,15 @@ public async Task<Result<StreakInfoResponse>> Handle(GetStreakInfoQuery request,
user.CurrentStreak,
user.LongestStreak,
user.LastActiveDate,
freezesUsed,
freezesAvailable,
freezesUsedThisMonth,
freezesAvailableToUse,
AppConstants.MaxStreakFreezesPerMonth,
isFrozenToday,
recentFreezeDates));
recentFreezeDates,
user.StreakFreezesAccumulated,
AppConstants.MaxStreakFreezesAccumulated,
daysUntilNextFreeze,
freezesAvailableToUse,
canEarnMore));
}
}
41 changes: 40 additions & 1 deletion src/Orbit.Domain/Entities/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public partial class User : Entity
public int CurrentStreak { get; private set; } = 0;
public int LongestStreak { get; private set; } = 0;
public DateOnly? LastActiveDate { get; private set; }
public int StreakFreezesAccumulated { get; private set; } = 0;
public int LastFreezeAwardStreak { get; private set; } = 0;
public string? ThemePreference { get; private set; }
public string? ColorScheme { get; private set; }

Expand Down Expand Up @@ -323,11 +325,46 @@ public void ApplyStreakFreeze(DateOnly today)

public void SetStreakState(int currentStreak, int longestStreak, DateOnly? lastActiveDate)
{
CurrentStreak = Math.Max(0, currentStreak);
var normalizedStreak = Math.Max(0, currentStreak);
if (normalizedStreak == 0 && CurrentStreak > 0)
{
// Streak broke; reset the "last award" marker so the next 7-day run triggers a fresh award.
LastFreezeAwardStreak = 0;
}
Comment thread
thomasluizon marked this conversation as resolved.
Outdated
CurrentStreak = normalizedStreak;
LongestStreak = Math.Max(CurrentStreak, longestStreak);
LastActiveDate = lastActiveDate;
}

public bool AwardStreakFreezeIfEligible(int maxAccumulated = 3, int daysPerFreeze = 7)
{
if (CurrentStreak < daysPerFreeze)
return false;

if (StreakFreezesAccumulated >= maxAccumulated)
{
// Cap reached; advance marker to the most recent earned milestone so we don't overshoot later.
LastFreezeAwardStreak = CurrentStreak - (CurrentStreak % daysPerFreeze);
return false;
}

var eligibleMilestone = CurrentStreak - (CurrentStreak % daysPerFreeze);
if (eligibleMilestone <= LastFreezeAwardStreak)
return false;

StreakFreezesAccumulated++;
LastFreezeAwardStreak = LastFreezeAwardStreak + daysPerFreeze;
return true;
}

public Result ConsumeStreakFreeze()
{
if (StreakFreezesAccumulated <= 0)
return Result.Failure("No streak freezes accumulated");
StreakFreezesAccumulated--;
return Result.Success();
}

/// <summary>
/// Resets all user profile fields to their default state while preserving
/// identity, preferences, and subscription data.
Expand All @@ -341,6 +378,8 @@ public void ResetAccount()
CurrentStreak = 0;
LongestStreak = 0;
LastActiveDate = null;
StreakFreezesAccumulated = 0;
LastFreezeAwardStreak = 0;
AiMessagesUsedThisMonth = 0;
AiMessagesResetAt = null;
AdRewardBonusMessages = 0;
Expand Down
Loading
Loading