From 0de98a9bc7d1252475bbc05ce84cdb7073c625e2 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Mon, 19 Oct 2026 23:31:17 -0300 Subject: [PATCH 1/2] fix: preserve yearly streak gap repairs through recalculation --- .../Content/FeatureExplanations/streaks.md | 2 +- src/Orbit.Application/Common/AppConstants.cs | 3 +- .../Habits/Services/HabitScheduleService.cs | 12 +++- .../GetStreakHistoryQueryHandlerTests.cs | 23 +++++++ .../Services/HabitScheduleServiceTests.cs | 53 ++++++++++++--- .../Services/StreakGapRepairTests.cs | 68 ++++++++++++++++--- 6 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md index 2548b2671..e7aecc53d 100644 --- a/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md @@ -34,6 +34,6 @@ If you have no recurring habits at all yet, the streak falls back to simple **ca ## Lookback limit -Streak calculation looks back at most **365** days (`MaxStreakLookbackDays`). Activity older than a year does not extend the current streak. +Streak calculation looks back at most **732** days (`MaxStreakLookbackDays`). This includes the completed occurrence before a repaired yearly gap. Activity outside this window does not extend the current streak. The longest streak is tracked separately by scanning your full scheduled-day history for the longest unbroken run; it never decreases when the current streak resets. diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index 7e24b5738..33fd69ef3 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -69,7 +69,8 @@ public static class AppConstants public const int MaxStreakFreezesPerMonth = 3; public const int MaxStreakFreezesAccumulated = 3; public const int StreakDaysPerFreeze = 7; - public const int MaxStreakLookbackDays = 365; + /// Two annual intervals retain a completed predecessor while a repaired yearly occurrence remains current. + public const int MaxStreakLookbackDays = 2 * 366; public const int MaxSyncWindowDays = 30; public const int SyncCleanupMarginDays = 1; public const int MaxChallengeTitleLength = 200; diff --git a/src/Orbit.Application/Habits/Services/HabitScheduleService.cs b/src/Orbit.Application/Habits/Services/HabitScheduleService.cs index 044c0852b..1e632e13b 100644 --- a/src/Orbit.Application/Habits/Services/HabitScheduleService.cs +++ b/src/Orbit.Application/Habits/Services/HabitScheduleService.cs @@ -107,8 +107,16 @@ public static HashSet GetUnionScheduledDatesForStreak( TimeZoneInfo.ConvertTimeFromUtc(habit.CreatedAtUtc, userTimeZone)); var effectiveFrom = from < habitStart ? habitStart : from; - foreach (var date in GetStreakScheduledDates(habit, effectiveFrom, to, habitStart, weekStartDay)) - union.Add(date); + var sliceFrom = effectiveFrom; + while (sliceFrom <= to) + { + var sliceTo = sliceFrom.AddDays(Math.Min(AppConstants.MaxRangeDays, to.DayNumber - sliceFrom.DayNumber)); + foreach (var date in GetStreakScheduledDates(habit, sliceFrom, sliceTo, habitStart, weekStartDay)) + union.Add(date); + if (sliceTo == to) + break; + sliceFrom = sliceTo.AddDays(1); + } } return union; } diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakHistoryQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakHistoryQueryHandlerTests.cs index f64c5efbf..18f383e35 100644 --- a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakHistoryQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakHistoryQueryHandlerTests.cs @@ -101,6 +101,29 @@ private void ArrangeFreezes(params StreakFreeze[] freezes) .Returns(freezes.ToList()); } + [Fact] + public async Task Handle_RepairedYearlyGap_RetainsPredecessorAndRecentCompletion() + { + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(CreateProUser()); + var today = new DateOnly(2024, 9, 6); + var gap = today.AddDays(-1); + var predecessor = gap.AddYears(-1); + var habit = Habit.Create(new HabitCreateParams(UserId, "Annual review", FrequencyUnit.Year, 1, + DueDate: predecessor)).Value; + BackdateCreation(habit, predecessor); + Log(habit, predecessor, gap.AddYears(1)); + ArrangeHabits(habit); + ArrangeFreezes(StreakFreeze.Create(UserId, gap, StreakFreezeOrigin.Manual)); + + var result = await _handler.Handle( + new GetStreakHistoryQuery(UserId, gap, gap.AddYears(1)), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Points[0].Streak.Should().Be(1); + result.Value.Points[^1].Streak.Should().Be(2); + result.Value.Points.Should().OnlyContain(point => point.Streak > 0); + } + [Fact] public async Task Handle_ProUser_IncrementsStreakForConsecutiveScheduledDays() { diff --git a/tests/Orbit.Application.Tests/Services/HabitScheduleServiceTests.cs b/tests/Orbit.Application.Tests/Services/HabitScheduleServiceTests.cs index 3ac461671..a2644a471 100644 --- a/tests/Orbit.Application.Tests/Services/HabitScheduleServiceTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitScheduleServiceTests.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using FluentAssertions; using NSubstitute; +using Orbit.Application.Common; using Orbit.Application.Habits.Services; using Orbit.Domain.Entities; using Orbit.Domain.Enums; @@ -12,6 +13,38 @@ public class HabitScheduleServiceTests { private static readonly Guid UserId = Guid.NewGuid(); private static readonly DateOnly Anchor = new(2025, 1, 6); + + [Theory] + [InlineData(366)] + [InlineData(367)] + [InlineData(732)] + [InlineData(794)] + public void StreakSchedule_LongRangeIncludesEveryDateAcrossSliceBoundaries(int days) + { + var habit = CreateHabit(FrequencyUnit.Day, 1); + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))!.SetValue(habit, + Anchor.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + var to = Anchor.AddDays(days); + + var dates = HabitScheduleService.GetUnionScheduledDatesForStreak([habit], Anchor, to, TimeZoneInfo.Utc); + + dates.Should().BeEquivalentTo(Enumerable.Range(0, days + 1).Select(Anchor.AddDays)); + var state = HabitScheduleService.ComputeStreakAsOf(dates, dates, [], Anchor, to); + state.Streak.Should().Be(days + 1); + state.LastActiveDate.Should().Be(to); + } + + [Fact] + public void ScheduledDates_LongRangeRetainsThePublicRangeCap() + { + var habit = CreateHabit(FrequencyUnit.Day, 1); + + var dates = HabitScheduleService.GetScheduledDates(habit, Anchor, Anchor.AddDays(732)); + + dates.Should().HaveCount(AppConstants.MaxRangeDays + 1); + dates[^1].Should().Be(Anchor.AddDays(AppConstants.MaxRangeDays)); + } + private static Habit CreateHabit( FrequencyUnit? unit, int? qty, @@ -537,7 +570,7 @@ public void GetCompletedInWindow_SundayStart_SplitsLogsAcrossWeekBoundary() { var habit = CreateHabit(FrequencyUnit.Week, 3, dueDate: new DateOnly(2025, 1, 5), isFlexible: true); - habit.Log(new DateOnly(2025, 1, 11)); habit.Log(new DateOnly(2025, 1, 12)); + habit.Log(new DateOnly(2025, 1, 11)); habit.Log(new DateOnly(2025, 1, 12)); var sundayWeek = HabitScheduleService.GetCompletedInWindow(habit, new DateOnly(2025, 1, 11), habit.Logs, weekStartDay: 0); var mondayWeek = HabitScheduleService.GetCompletedInWindow(habit, new DateOnly(2025, 1, 11), habit.Logs, weekStartDay: 1); @@ -582,7 +615,8 @@ public void GetRemainingCompletions_ReturnsCorrectRemaining() var remaining = HabitScheduleService.GetRemainingCompletions(habit, monday, habit.Logs, weekStartDay: 1); - remaining.Should().Be(2); } + remaining.Should().Be(2); + } [Fact] public void GetRemainingCompletions_NeverNegative() @@ -723,7 +757,8 @@ public void IsHabitDueOnDate_DailyWithDays_AllMatchingDaysAreTrue() FrequencyUnit.Day, 1, days: new[] { DayOfWeek.Monday, DayOfWeek.Wednesday, DayOfWeek.Friday }); - HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 6)).Should().BeTrue(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 7)).Should().BeFalse(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 8)).Should().BeTrue(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 9)).Should().BeFalse(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 10)).Should().BeTrue(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 11)).Should().BeFalse(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 12)).Should().BeFalse(); } + HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 6)).Should().BeTrue(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 7)).Should().BeFalse(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 8)).Should().BeTrue(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 9)).Should().BeFalse(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 10)).Should().BeTrue(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 11)).Should().BeFalse(); HabitScheduleService.IsHabitDueOnDate(habit, new DateOnly(2025, 1, 12)).Should().BeFalse(); + } [Fact] public void HasMissedPastOccurrence_OneTimeTask_ReturnsFalse() @@ -833,7 +868,7 @@ public void GetScheduledDates_WeeklyHabit_ReturnsCorrectDates() var dates = HabitScheduleService.GetScheduledDates(habit, from, to); - dates.Should().HaveCount(5); dates.Should().AllSatisfy(d => d.DayOfWeek.Should().Be(DayOfWeek.Monday)); + dates.Should().HaveCount(5); dates.Should().AllSatisfy(d => d.DayOfWeek.Should().Be(DayOfWeek.Monday)); } [Fact] @@ -845,7 +880,8 @@ public void GetScheduledDates_MonthlyHabit_ReturnsCorrectDates() var dates = HabitScheduleService.GetScheduledDates(habit, from, to); - dates.Should().HaveCount(4); } + dates.Should().HaveCount(4); + } [Fact] public void GetHistoricalScheduledDates_DueDateAdvancedPastWindow_UsesScheduledStart() @@ -1055,7 +1091,7 @@ public void GetSkippedInWindow_CountsOnlySkipLogs() var monday = new DateOnly(2025, 1, 6); var habit = CreateHabit(FrequencyUnit.Week, 3, dueDate: monday, isFlexible: true); - habit.Log(monday); habit.SkipFlexible(monday.AddDays(1)); + habit.Log(monday); habit.SkipFlexible(monday.AddDays(1)); var skipped = HabitScheduleService.GetSkippedInWindow(habit, monday, habit.Logs, weekStartDay: 1); skipped.Should().Be(1); @@ -1070,7 +1106,8 @@ public void GetRemainingCompletions_SkipsReduceTarget() habit.SkipFlexible(monday); var remaining = HabitScheduleService.GetRemainingCompletions(habit, monday, habit.Logs, weekStartDay: 1); - remaining.Should().Be(2); } + remaining.Should().Be(2); + } [Fact] public void HasCompletedLogInRange_CompletionLogInRange_ReturnsTrue() @@ -1136,7 +1173,7 @@ public void IsOverdueOnDate_OneTimeTaskDueInFuture_ReturnsFalse() [Fact] public void IsOverdueOnDate_RecurringMissedPastOccurrence_ReturnsTrue() { - var habit = CreateHabit(FrequencyUnit.Week, 1, dueDate: Anchor); var laterWednesday = Anchor.AddDays(9); + var habit = CreateHabit(FrequencyUnit.Week, 1, dueDate: Anchor); var laterWednesday = Anchor.AddDays(9); HabitScheduleService.IsOverdueOnDate(habit, laterWednesday).Should().BeTrue(); } diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs index 0cc022ddc..9e562a52b 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs @@ -387,12 +387,62 @@ public async Task WeeklyGapWhosePriorOccurrenceWasAlsoMissed_IsUnavailable() (await _service.EvaluateGapRepairAsync(_user.Id, _today, [_today.AddDays(-1)])).Should().BeNull(); } - [Fact] - public async Task YearlyGapWhosePredecessorSitsBeyondTheStreakWindow_IsRefused() + [Theory] + [InlineData(2024, FrequencyUnit.Year, 366)] + [InlineData(2026, FrequencyUnit.Year, 365)] + [InlineData(2026, FrequencyUnit.Day, 365)] + public async Task YearlyGapWhosePredecessorSitsBeyondTheOrdinaryWindow_IsRepairable( + int year, FrequencyUnit unit, int intervalDays) { - _habit = SetYearlyHistory(); + var today = new DateOnly(year, 9, 6); + _dateService.GetUserTodayAsync(_user.Id, Arg.Any()).Returns(today); + _habit = SetYearlyHistory(today, unit); + var gapDate = today.AddDays(-1); + var precedingDate = _habit.Logs.Single().Date; + (gapDate.DayNumber - precedingDate.DayNumber).Should().Be(intervalDays); - (await _service.EvaluateGapRepairAsync(_user.Id, _today, [_today.AddDays(-1)])).Should().BeNull(); + var state = await _service.EvaluateGapRepairAsync(_user.Id, today, [gapDate]); + + state.Should().NotBeNull(); + state!.CurrentStreak.Should().Be(1); + state.PrecedingScheduledDate.Should().Be(precedingDate); + (await GetStreakInfo()).RepairableGapDates.Should().Equal(gapDate); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(364)] + public async Task RepairedYearlyGap_RecalculationPersistsTheStreak(int daysAfterRepair) + { + var today = new DateOnly(2024, 9, 6); + _dateService.GetUserTodayAsync(_user.Id, Arg.Any()).Returns(today); + _habit = SetYearlyHistory(today); + SetUserProperty(nameof(User.StreakFreezesAccumulated), 1); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"YearlyGap_{Guid.NewGuid()}").Options; + await using var context = new OrbitDbContext(options); + context.Users.Add(_user); + await context.SaveChangesAsync(); + var (handler, _) = BuildRepairHandler(); + + var repaired = await handler.Handle(new(_user.Id, [today.AddDays(-1)]), CancellationToken.None); + + repaired.IsSuccess.Should().BeTrue(); + repaired.Value.CurrentStreak.Should().Be(1); + repaired.Value.StreakFreezesAccumulated.Should().Be(0); + _persistedFreezes.Select(freeze => freeze.UsedOnDate).Should().Equal(today.AddDays(-1)); + await context.SaveChangesAsync(); + + _dateService.GetUserTodayAsync(_user.Id, Arg.Any()).Returns(today.AddDays(daysAfterRepair)); + await _service.RecalculateAsync(_user.Id); + await context.SaveChangesAsync(); + + await using var reloaded = new OrbitDbContext(options); + var persistedUser = await reloaded.Users.SingleAsync(user => user.Id == _user.Id); + persistedUser.CurrentStreak.Should().Be(1); + persistedUser.LastActiveDate.Should().Be(today.AddDays(-1)); + persistedUser.StreakFreezesAccumulated.Should().Be(0); } /// @@ -614,13 +664,13 @@ private Habit SetWeeklyHistory( /// /// A yearly habit occurring on yesterday and on the same day a year earlier, the earlier one - /// completed. Its predecessor is 366 days before the gap, which is the boundary the 365-day - /// history window used to cut off. + /// completed. The optional daily cadence places occurrences exactly 365 days apart. /// - private Habit SetYearlyHistory() + private Habit SetYearlyHistory(DateOnly? today = null, FrequencyUnit unit = FrequencyUnit.Year) { - var firstOccurrence = _today.AddDays(-1).AddYears(-1); - var habit = Habit.Create(new HabitCreateParams(_user.Id, "Annual review", FrequencyUnit.Year, 1, + var gapDate = (today ?? _today).AddDays(-1); + var firstOccurrence = unit == FrequencyUnit.Year ? gapDate.AddYears(-1) : gapDate.AddDays(-365); + var habit = Habit.Create(new HabitCreateParams(_user.Id, "Annual review", unit, unit == FrequencyUnit.Year ? 1 : 365, DueDate: firstOccurrence)).Value; typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))!.SetValue(habit, firstOccurrence.ToDateTime(new TimeOnly(12, 0), DateTimeKind.Utc)); From dc641166dda210d9f7800ac6bc489a6c62f23a9b Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Tue, 20 Oct 2026 00:23:37 -0300 Subject: [PATCH 2/2] fix: isolate yearly streak repair history window --- .../Content/FeatureExplanations/streaks.md | 4 +- src/Orbit.Application/Common/AppConstants.cs | 3 +- .../Queries/GetStreakHistoryQuery.cs | 2 +- .../Services/UserStreakService.cs | 10 ++-- .../Chat/FeatureExplanationDriftTests.cs | 2 +- .../RepairStreakGapCommandValidatorTests.cs | 17 ++++-- .../Services/StreakGapRepairTests.cs | 59 ++++++++++++------- 7 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md index e7aecc53d..c9aab884f 100644 --- a/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md @@ -8,7 +8,7 @@ derived_from: - src/Orbit.Application/Habits/Services/HabitScheduleService.cs ComputeStreakAsOf - src/Orbit.Infrastructure/Services/UserStreakService.cs LoadStreakDataAsync - src/Orbit.Infrastructure/Services/UserStreakService.cs CalendarFallback - - src/Orbit.Application/Common/AppConstants.cs MaxStreakLookbackDays + - src/Orbit.Application/Common/AppConstants.cs StreakRepairWindowDays --- # Streaks @@ -34,6 +34,6 @@ If you have no recurring habits at all yet, the streak falls back to simple **ca ## Lookback limit -Streak calculation looks back at most **732** days (`MaxStreakLookbackDays`). This includes the completed occurrence before a repaired yearly gap. Activity outside this window does not extend the current streak. +Streak calculation looks back at most **732** days (`StreakRepairWindowDays`). This includes the completed occurrence before a repaired yearly gap. Activity outside this window does not extend the current streak. The longest streak is tracked separately by scanning your full scheduled-day history for the longest unbroken run; it never decreases when the current streak resets. diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index 33fd69ef3..25c191c72 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -69,8 +69,9 @@ public static class AppConstants public const int MaxStreakFreezesPerMonth = 3; public const int MaxStreakFreezesAccumulated = 3; public const int StreakDaysPerFreeze = 7; + public const int MaxStreakLookbackDays = 365; /// Two annual intervals retain a completed predecessor while a repaired yearly occurrence remains current. - public const int MaxStreakLookbackDays = 2 * 366; + public const int StreakRepairWindowDays = 2 * 366; public const int MaxSyncWindowDays = 30; public const int SyncCleanupMarginDays = 1; public const int MaxChallengeTitleLength = 200; diff --git a/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQuery.cs b/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQuery.cs index a09ff9df7..6b729e100 100644 --- a/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQuery.cs +++ b/src/Orbit.Application/Gamification/Queries/GetStreakHistoryQuery.cs @@ -34,7 +34,7 @@ public async Task> Handle(GetStreakHistoryQuery re if (!unlocked) return Result.PayGateFailure("Streak insights are a Pro feature. Upgrade to unlock!"); - var seedFrom = request.DateFrom.AddDays(-AppConstants.MaxStreakLookbackDays); + var seedFrom = request.DateFrom.AddDays(-AppConstants.StreakRepairWindowDays); var allHabits = await habitRepository.FindAsync(h => h.UserId == request.UserId, cancellationToken); var streakEligibleHabitIds = allHabits diff --git a/src/Orbit.Infrastructure/Services/UserStreakService.cs b/src/Orbit.Infrastructure/Services/UserStreakService.cs index 12f258d75..961f046df 100644 --- a/src/Orbit.Infrastructure/Services/UserStreakService.cs +++ b/src/Orbit.Infrastructure/Services/UserStreakService.cs @@ -64,7 +64,7 @@ public class UserStreakService( if (user is null) return null; - var lookbackStart = userToday.AddDays(-AppConstants.MaxStreakLookbackDays); + var lookbackStart = userToday.AddDays(-AppConstants.StreakRepairWindowDays); var (completionDateSet, freezeDateSet, eligibleHabits) = await LoadStreakDataAsync(userId, lookbackStart, cancellationToken); @@ -92,7 +92,7 @@ public class UserStreakService( if (user is null) return null; - var lookbackStart = userToday.AddDays(-AppConstants.MaxStreakLookbackDays); + var lookbackStart = userToday.AddDays(-AppConstants.StreakRepairWindowDays); var gapStart = dates.Min(); if (gapStart <= lookbackStart) return null; @@ -116,7 +116,7 @@ public async Task> GetRepairableGapDatesAsync( if (user is null) return []; - var lookbackStart = userToday.AddDays(-AppConstants.MaxStreakLookbackDays); + var lookbackStart = userToday.AddDays(-AppConstants.StreakRepairWindowDays); var (completions, freezes, eligibleHabits) = await LoadStreakDataAsync(userId, lookbackStart, cancellationToken); var expectedDates = GetExpectedDates(user, lookbackStart, userToday, eligibleHabits); @@ -245,7 +245,7 @@ internal static StreakRepairEvaluation EvaluateRepair( if (!contributingHabits.Any(habit => habit.FrequencyUnit is not null)) return StreakRepairEvaluation.Unavailable(missedDate); - var lookbackStart = userToday.AddDays(-AppConstants.MaxStreakLookbackDays); + var lookbackStart = userToday.AddDays(-AppConstants.StreakRepairWindowDays); var userTimeZone = TimeZoneHelper.FindTimeZone(user.TimeZone, userId: user.Id); var expectedDates = HabitScheduleService.GetUnionScheduledDatesForStreak( @@ -321,7 +321,7 @@ private async Task CalculateStateAsync( CancellationToken cancellationToken) { var userToday = await userDateService.GetUserTodayAsync(userId, cancellationToken); - var lookbackStart = userToday.AddDays(-AppConstants.MaxStreakLookbackDays); + var lookbackStart = userToday.AddDays(-AppConstants.StreakRepairWindowDays); var (completionDateSet, freezeDateSet, eligibleHabits) = await LoadStreakDataAsync(userId, lookbackStart, cancellationToken); var contributingHabits = GetContributingHabits(eligibleHabits); diff --git a/tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs b/tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs index 8afa17a27..e7344ff0b 100644 --- a/tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs +++ b/tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs @@ -38,7 +38,7 @@ public void Freezes_MatchesStreakFreezeConstants() [Fact] public void Streaks_MatchesLookbackConstant() { - Body("streaks").Should().Contain(N(AppConstants.MaxStreakLookbackDays)); + Body("streaks").Should().Contain(N(AppConstants.StreakRepairWindowDays)); } [Fact] diff --git a/tests/Orbit.Application.Tests/Validators/RepairStreakGapCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/RepairStreakGapCommandValidatorTests.cs index 4401fb9c8..abc689783 100644 --- a/tests/Orbit.Application.Tests/Validators/RepairStreakGapCommandValidatorTests.cs +++ b/tests/Orbit.Application.Tests/Validators/RepairStreakGapCommandValidatorTests.cs @@ -1,5 +1,4 @@ using FluentValidation.TestHelper; -using Orbit.Application.Common; using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Validators; @@ -49,11 +48,17 @@ public void MissingInputs_AreInvalid() .ShouldHaveValidationErrorFor(command => command.Dates); } - [Fact] - public void DatesBeyondHistoryWindow_AreInvalid() + [Theory] + [InlineData(364, true)] + [InlineData(365, false)] + public void RepairDateCount_RetainsTheOrdinaryLimit(int count, bool isValid) { - var dates = Enumerable.Range(0, AppConstants.MaxStreakLookbackDays).Select(offset => Yesterday.AddDays(-offset)).ToArray(); - _validator.TestValidate(new RepairStreakGapCommand(Guid.NewGuid(), dates)) - .ShouldHaveValidationErrorFor(command => command.Dates); + var dates = Enumerable.Range(0, count).Select(offset => Yesterday.AddDays(-offset)).ToArray(); + var result = _validator.TestValidate(new RepairStreakGapCommand(Guid.NewGuid(), dates)); + + if (isValid) + result.ShouldNotHaveAnyValidationErrors(); + else + result.ShouldHaveValidationErrorFor(command => command.Dates); } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs index 9e562a52b..d76eefc28 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs @@ -305,21 +305,33 @@ public async Task MissingPredecessor_ReturnsNoGap() response.Value.RepairableGapDates.Should().BeEmpty(); } - [Fact] - public async Task GapOpeningLookbackWindow_ReturnsNoGap() + [Theory] + [InlineData(732, true)] + [InlineData(733, false)] + public async Task GapPredecessor_Respects732DayLookback(int predecessorDaysAgo, bool isRepairable) { - var firstDate = _today.AddDays(-AppConstants.MaxStreakLookbackDays); - var habit = Habit.Create(new HabitCreateParams(_user.Id, "Long gap", FrequencyUnit.Day, 1, + var firstDate = _today.AddDays(-predecessorDaysAgo); + var habit = Habit.Create(new HabitCreateParams(_user.Id, "Long interval", FrequencyUnit.Day, predecessorDaysAgo - 1, DueDate: firstDate)).Value; typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))!.SetValue(habit, firstDate.ToDateTime(new TimeOnly(12, 0), DateTimeKind.Utc)); - _habits.FindAsync(Arg.Any>>(), Arg.Any()).Returns([habit]); - _logs.FindAsync(Arg.Any>>(), Arg.Any()).Returns([]); + habit.Log(firstDate, advanceDueDate: false); StubStreakData(habit); + var state = await _service.EvaluateGapRepairAsync(_user.Id, _today, [_today.AddDays(-1)]); var response = await ReadStreakInfoAsync(); - response.Value.RepairableGapDates.Should().BeEmpty(); + if (isRepairable) + { + state.Should().NotBeNull(); + state!.PrecedingScheduledDate.Should().Be(firstDate); + response.Value.RepairableGapDates.Should().Equal(_today.AddDays(-1)); + } + else + { + state.Should().BeNull(); + response.Value.RepairableGapDates.Should().BeEmpty(); + } } [Fact] @@ -370,7 +382,7 @@ public async Task GapWithMissedPredecessor_IsNotReturnedPartially() [Fact] public async Task GapOpeningLookbackWindow_IsNotReturned() { - _habit = SetHistory(_today, AppConstants.MaxStreakLookbackDays, precedingCompletions: 0); + _habit = SetHistory(_today, AppConstants.StreakRepairWindowDays, precedingCompletions: 0); var streak = await GetStreakInfo(); @@ -388,13 +400,14 @@ public async Task WeeklyGapWhosePriorOccurrenceWasAlsoMissed_IsUnavailable() } [Theory] - [InlineData(2024, FrequencyUnit.Year, 366)] - [InlineData(2026, FrequencyUnit.Year, 365)] - [InlineData(2026, FrequencyUnit.Day, 365)] + [InlineData(2024, 9, 6, FrequencyUnit.Year, 366)] + [InlineData(2026, 9, 6, FrequencyUnit.Year, 365)] + [InlineData(2026, 9, 6, FrequencyUnit.Day, 365)] + [InlineData(2024, 3, 1, FrequencyUnit.Year, 366)] public async Task YearlyGapWhosePredecessorSitsBeyondTheOrdinaryWindow_IsRepairable( - int year, FrequencyUnit unit, int intervalDays) + int year, int month, int day, FrequencyUnit unit, int intervalDays) { - var today = new DateOnly(year, 9, 6); + var today = new DateOnly(year, month, day); _dateService.GetUserTodayAsync(_user.Id, Arg.Any()).Returns(today); _habit = SetYearlyHistory(today, unit); var gapDate = today.AddDays(-1); @@ -410,12 +423,14 @@ public async Task YearlyGapWhosePredecessorSitsBeyondTheOrdinaryWindow_IsRepaira } [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(364)] - public async Task RepairedYearlyGap_RecalculationPersistsTheStreak(int daysAfterRepair) - { - var today = new DateOnly(2024, 9, 6); + [InlineData(9, 6, 0)] + [InlineData(9, 6, 1)] + [InlineData(9, 6, 364)] + [InlineData(3, 1, 0)] + [InlineData(3, 1, 364)] + public async Task RepairedYearlyGap_RecalculationPersistsTheStreak(int month, int day, int daysAfterRepair) + { + var today = new DateOnly(2024, month, day); _dateService.GetUserTodayAsync(_user.Id, Arg.Any()).Returns(today); _habit = SetYearlyHistory(today); SetUserProperty(nameof(User.StreakFreezesAccumulated), 1); @@ -663,15 +678,15 @@ private Habit SetWeeklyHistory( } /// - /// A yearly habit occurring on yesterday and on the same day a year earlier, the earlier one - /// completed. The optional daily cadence places occurrences exactly 365 days apart. + /// A yearly habit anchored to yesterday, with the previous year's occurrence completed. + /// A Feb 29 anchor uses Feb 28 in the previous year. The daily cadence spaces occurrences 365 days apart. /// private Habit SetYearlyHistory(DateOnly? today = null, FrequencyUnit unit = FrequencyUnit.Year) { var gapDate = (today ?? _today).AddDays(-1); var firstOccurrence = unit == FrequencyUnit.Year ? gapDate.AddYears(-1) : gapDate.AddDays(-365); var habit = Habit.Create(new HabitCreateParams(_user.Id, "Annual review", unit, unit == FrequencyUnit.Year ? 1 : 365, - DueDate: firstOccurrence)).Value; + DueDate: gapDate)).Value; typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))!.SetValue(habit, firstOccurrence.ToDateTime(new TimeOnly(12, 0), DateTimeKind.Utc)); habit.Log(firstOccurrence, advanceDueDate: false);