From 9dfb065e4bf67c4c3ea7b2a48c9150d1df164827 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 13 Sep 2026 17:46:48 -0300 Subject: [PATCH 1/3] Start repairable gap contract From 942fe9053ad77e6ef27836b8983a150994b8fc07 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 13 Sep 2026 17:59:44 -0300 Subject: [PATCH 2/3] Expose repairable streak gap dates --- src/Orbit.Api/openapi.json | 10 ++ .../Queries/GetStreakInfoQuery.cs | 10 +- .../Interfaces/IUserStreakService.cs | 5 + .../Services/UserStreakService.cs | 147 +++++++++++++----- .../GetStreakInfoQueryHandlerTests.cs | 12 ++ .../Services/StreakGapRepairTests.cs | 75 +++++++++ 6 files changed, 222 insertions(+), 37 deletions(-) diff --git a/src/Orbit.Api/openapi.json b/src/Orbit.Api/openapi.json index 68bbb0c51..c89b9e7ae 100644 --- a/src/Orbit.Api/openapi.json +++ b/src/Orbit.Api/openapi.json @@ -15369,6 +15369,16 @@ "string" ], "format": "int32" + }, + "repairableGapDates": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string", + "format": "date" + } } } }, diff --git a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs index fdcf2329d..736ee570a 100644 --- a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs +++ b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs @@ -27,7 +27,8 @@ public record StreakInfoResponse( DateOnly? RepairDate, int RepairsRemainingThisMonth, DateOnly? LastFreezeCoveredDate = null, - int? FreezeBankRemaining = null); + int? FreezeBankRemaining = null, + IReadOnlyList? RepairableGapDates = null); public record GetStreakInfoQuery(Guid UserId) : IRequest>, IConcurrencyRetryable; @@ -94,6 +95,10 @@ public async Task> Handle(GetStreakInfoQuery request, cancellationToken); var isRepairAvailable = repair?.IsAvailable == true; DateOnly? repairDate = isRepairAvailable ? repair!.MissedDate : null; + var repairableGapDates = await userStreakService.GetRepairableGapDatesAsync( + request.UserId, + today, + cancellationToken); if (isRepairAvailable) { @@ -138,6 +143,7 @@ public async Task> Handle(GetStreakInfoQuery request, repairDate, freezesAvailableToUse, lastFreezeCoveredDate, - freezeBankRemaining)); + freezeBankRemaining, + repairableGapDates)); } } diff --git a/src/Orbit.Domain/Interfaces/IUserStreakService.cs b/src/Orbit.Domain/Interfaces/IUserStreakService.cs index 5d59aa06b..1b6326630 100644 --- a/src/Orbit.Domain/Interfaces/IUserStreakService.cs +++ b/src/Orbit.Domain/Interfaces/IUserStreakService.cs @@ -4,6 +4,11 @@ namespace Orbit.Domain.Interfaces; public interface IUserStreakService { + Task> GetRepairableGapDatesAsync( + Guid userId, + DateOnly userToday, + CancellationToken cancellationToken = default); + Task EvaluateGapRepairAsync( Guid userId, DateOnly userToday, diff --git a/src/Orbit.Infrastructure/Services/UserStreakService.cs b/src/Orbit.Infrastructure/Services/UserStreakService.cs index 2701fe7f7..e08208bcb 100644 --- a/src/Orbit.Infrastructure/Services/UserStreakService.cs +++ b/src/Orbit.Infrastructure/Services/UserStreakService.cs @@ -81,6 +81,39 @@ public class UserStreakService( freezeDateSet); } + public async Task> GetRepairableGapDatesAsync( + Guid userId, + DateOnly userToday, + CancellationToken cancellationToken = default) + { + var user = await repos.Users.FindOneTrackedAsync( + candidate => candidate.Id == userId, + cancellationToken: cancellationToken); + if (user is null) + return []; + + var context = await LoadGapRepairContextAsync(userId, user, userToday, cancellationToken); + if (context is null) + return []; + + var gapEnd = userToday.AddDays(-1); + var gapEndIndex = Array.IndexOf(context.ScheduledDates, gapEnd); + if (gapEndIndex < 0 || IsCovered(context, gapEnd)) + return []; + + var precedingDate = user.PreGapLastActiveDate ?? user.LastActiveDate; + if (!precedingDate.HasValue) + return []; + + var precedingIndex = Array.IndexOf(context.ScheduledDates, precedingDate.Value); + var gapStartIndex = precedingIndex + 1; + if (precedingIndex < 0 || gapStartIndex > gapEndIndex) + return []; + + var dates = context.ScheduledDates[gapStartIndex..(gapEndIndex + 1)]; + return EvaluateGapRepair(user, userToday, dates, context) is null ? [] : dates; + } + public async Task EvaluateGapRepairAsync( Guid userId, DateOnly userToday, @@ -96,18 +129,22 @@ public class UserStreakService( if (user is null) return null; + var context = await LoadGapRepairContextAsync(userId, user, userToday, cancellationToken); + return context is null ? null : EvaluateGapRepair(user, userToday, dates, context); + } + + private async Task LoadGapRepairContextAsync( + Guid userId, + User user, + DateOnly userToday, + CancellationToken cancellationToken) + { /** - * ONE window, and it is the streak engine's own. Eligibility must be decided over exactly the + * One window, and it is the streak engine's own. Eligibility must be decided over exactly the * history CalculateStateAsync computes from, or a repair can be accepted on evidence the engine - * cannot see: the response, and the next RecalculateAsync, would recompute a zero streak and - * persist it AFTER the freeze was spent. A yearly predecessor sits 366 days back, outside this - * window, so a yearly gap stays unrepairable rather than repairable-then-silently-undone. + * cannot see. A yearly predecessor sits outside this window, so that gap remains unrepairable. */ var lookbackStart = userToday.AddDays(-AppConstants.MaxStreakLookbackDays); - var gapStart = dates.Min(); - if (gapStart <= lookbackStart) - return null; - var (completions, freezes, eligibleHabits) = await LoadStreakDataAsync(userId, lookbackStart, cancellationToken); var contributingHabits = GetContributingHabits(eligibleHabits); @@ -117,17 +154,35 @@ public class UserStreakService( var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone, userId: user.Id); var expectedDates = HabitScheduleService.GetUnionScheduledDatesForStreak( contributingHabits, lookbackStart, userToday, timeZone, user.WeekStartDay); - if (dates.Any(date => !expectedDates.Contains(date) || completions.Contains(date) || freezes.Contains(date))) + return new GapRepairContext( + lookbackStart, + expectedDates, + expectedDates.Order().ToArray(), + completions, + freezes); + } + + private static UserStreakState? EvaluateGapRepair( + User user, + DateOnly userToday, + IReadOnlyCollection dates, + GapRepairContext context) + { + if (StreakFreeze.CreateGap(user.Id, dates, userToday).IsFailure) + return null; + + var gapStart = dates.Min(); + if (gapStart <= context.LookbackStart + || dates.Any(date => !context.ExpectedDates.Contains(date) || IsCovered(context, date))) + { return null; + } /** - * Streak continuity runs over SCHEDULED occurrences, never calendar days: a weekly habit's - * streak survives the six unscheduled days between two occurrences. Reading the calendar day - * before the gap made every weekly and every-N-day gap unrepairable, because that day is - * usually not scheduled and so carries neither a completion nor a freeze. + * Streak continuity runs over scheduled occurrences, never calendar days. A weekly habit's + * streak survives the six unscheduled days between two occurrences. */ - var scheduled = expectedDates.Order().ToArray(); - var gapStartIndex = Array.IndexOf(scheduled, gapStart); + var gapStartIndex = Array.IndexOf(context.ScheduledDates, gapStart); if (gapStartIndex < 0) return null; @@ -136,53 +191,75 @@ public class UserStreakService( * missed occurrence inside the gap and claim the streak carried across it. */ var orderedDates = dates.Order().ToArray(); - if (gapStartIndex + orderedDates.Length > scheduled.Length - || orderedDates.Where((date, index) => date != scheduled[gapStartIndex + index]).Any()) + if (gapStartIndex + orderedDates.Length > context.ScheduledDates.Length + || orderedDates.Where((date, index) => date != context.ScheduledDates[gapStartIndex + index]).Any()) { return null; } /** - * Index 0 means the gap opens the window with no predecessor inside it, so there is no evidence - * the streak was alive going in. + * Index zero means the gap opens the window with no predecessor inside it, so there is no + * evidence the streak was alive going in. */ if (gapStartIndex == 0) return null; - var precedingDate = scheduled[gapStartIndex - 1]; - if (!completions.Contains(precedingDate) && !freezes.Contains(precedingDate)) + var precedingDate = context.ScheduledDates[gapStartIndex - 1]; + if (!IsCovered(context, precedingDate)) return null; foreach (var month in dates.GroupBy(date => (date.Year, date.Month))) { - var used = freezes.Count(date => date.Year == month.Key.Year && date.Month == month.Key.Month); + var used = context.Freezes.Count(date => date.Year == month.Key.Year && date.Month == month.Key.Month); if (used + month.Count() > AppConstants.MaxStreakFreezesPerMonth) return null; } var (currentStreak, _) = HabitScheduleService.ComputeStreakAsOf( - expectedDates, completions, freezes, lookbackStart, userToday); - var repairedDates = new HashSet(freezes); + context.ExpectedDates, + context.Completions, + context.Freezes, + context.LookbackStart, + userToday); + var repairedDates = new HashSet(context.Freezes); repairedDates.UnionWith(dates); var (repairedStreak, lastActiveDate) = HabitScheduleService.ComputeStreakAsOf( - expectedDates, completions, repairedDates, lookbackStart, userToday); + context.ExpectedDates, + context.Completions, + repairedDates, + context.LookbackStart, + userToday); if (repairedStreak <= currentStreak) return null; /** - * The predecessor travels WITH the state. The handler restores the award cursor against it, and - * deriving it there as `gapStart - 1` was the same calendar-versus-schedule mistake one layer up. - * The streak AS OF the predecessor, which bounds the fallback award cursor. Without it the - * domain rounded the full repaired streak down and marked milestones crossed AFTER the gap as - * already awarded, so a row with no saved snapshot spent a freeze and never received the one it - * had just earned. + * The predecessor travels with the state. The handler restores the award cursor against the + * scheduled predecessor, never the prior calendar day. */ var (preGapStreak, _) = HabitScheduleService.ComputeStreakAsOf( - expectedDates, completions, freezes, lookbackStart, precedingDate); - return new UserStreakState(repairedStreak, - Math.Max(user.LongestStreak, ComputeLongestStreak(expectedDates, completions, repairedDates)), - lastActiveDate, precedingDate, preGapStreak); + context.ExpectedDates, + context.Completions, + context.Freezes, + context.LookbackStart, + precedingDate); + return new UserStreakState( + repairedStreak, + Math.Max(user.LongestStreak, + ComputeLongestStreak(context.ExpectedDates, context.Completions, repairedDates)), + lastActiveDate, + precedingDate, + preGapStreak); } + private static bool IsCovered(GapRepairContext context, DateOnly date) => + context.Completions.Contains(date) || context.Freezes.Contains(date); + + private sealed record GapRepairContext( + DateOnly LookbackStart, + HashSet ExpectedDates, + DateOnly[] ScheduledDates, + HashSet Completions, + HashSet Freezes); + internal static StreakRepairEvaluation EvaluateRepair( User user, DateOnly userToday, diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs index 0c9e765eb..11d011020 100644 --- a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs @@ -42,6 +42,11 @@ public GetStreakInfoQueryHandlerTests() Today.AddDays(-1), Arg.Any()) .Returns(StreakRepairEvaluation.Unavailable(Today.AddDays(-1))); + _userStreakService.GetRepairableGapDatesAsync( + UserId, + Today, + Arg.Any()) + .Returns(Array.Empty()); } private static User CreateTestUser() @@ -97,6 +102,7 @@ public async Task Handle_UserFound_ReturnsStreakInfo() result.Value.RepairsRemainingThisMonth.Should().Be(0); result.Value.LastFreezeCoveredDate.Should().BeNull(); result.Value.FreezeBankRemaining.Should().BeNull(); + result.Value.RepairableGapDates.Should().BeEmpty(); } [Fact] @@ -269,6 +275,11 @@ public async Task Handle_RepairAvailable_AppendsRepairFieldsAndCapturesOffer() .Returns(StreakRepairEvaluation.Available( Today.AddDays(-1), new UserStreakState(7, 7, Today.AddDays(-1)))); + _userStreakService.GetRepairableGapDatesAsync( + UserId, + Today, + Arg.Any()) + .Returns([Today.AddDays(-2), Today.AddDays(-1)]); var result = await _handler.Handle(new GetStreakInfoQuery(UserId), CancellationToken.None); @@ -276,6 +287,7 @@ public async Task Handle_RepairAvailable_AppendsRepairFieldsAndCapturesOffer() result.Value.IsRepairAvailable.Should().BeTrue(); result.Value.RepairDate.Should().Be(Today.AddDays(-1)); result.Value.RepairsRemainingThisMonth.Should().Be(1); + result.Value.RepairableGapDates.Should().Equal(Today.AddDays(-2), Today.AddDays(-1)); _productAnalytics.Received(1).CaptureUserEvent( UserId, "streak_repair_offered", diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs index 23ca6a364..f6adef707 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakGapRepairTests.cs @@ -223,6 +223,44 @@ public async Task OnlySuffixOfGap_IsUnavailable() result.Should().BeNull(); } + [Fact] + public async Task RepairableGapDates_DailyTwoDayGap_ReturnsBothDays() + { + _user.SetStreakState(3, 3, _today.AddDays(-3)); + + var result = await _service.GetRepairableGapDatesAsync(_user.Id, _today); + + result.Should().Equal(_today.AddDays(-2), _today.AddDays(-1)); + } + + [Fact] + public async Task RepairableGapDates_CompletionToday_ReturnsEarlierGap() + { + _user.SetStreakState(3, 3, _today.AddDays(-3)); + _user.SetStreakState(1, 3, _today); + _habit.Log(_today, advanceDueDate: false); + + var result = await _service.GetRepairableGapDatesAsync(_user.Id, _today); + + result.Should().Equal(_today.AddDays(-2), _today.AddDays(-1)); + } + + [Fact] + public async Task RepairableGapDates_GapOpeningLookbackWindow_ReturnsEmpty() + { + var lookbackStart = _today.AddDays(-AppConstants.MaxStreakLookbackDays); + _habit = Habit.Create(new HabitCreateParams(_user.Id, "Window", FrequencyUnit.Day, 1, + DueDate: lookbackStart)).Value; + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))!.SetValue(_habit, + lookbackStart.ToDateTime(new TimeOnly(12, 0), DateTimeKind.Utc)); + _habits.FindAsync(Arg.Any>>(), Arg.Any()).Returns([_habit]); + _logs.FindAsync(Arg.Any>>(), Arg.Any()).Returns([]); + + var result = await _service.GetRepairableGapDatesAsync(_user.Id, _today); + + result.Should().BeEmpty(); + } + [Fact] public async Task SelectionContainsCompletedDay_IsUnavailable() { @@ -273,6 +311,43 @@ public async Task WeeklyGapWhosePriorOccurrenceWasAlsoMissed_IsUnavailable() (await _service.EvaluateGapRepairAsync(_user.Id, _today, [_today.AddDays(-1)])).Should().BeNull(); } + [Fact] + public async Task RepairableGapDates_PrecedingOccurrenceIsMissed_ReturnsEmpty() + { + _habit = SetWeeklyHistory(priorOccurrences: 3, skipMostRecentCompletion: true); + _user.SetStreakState(2, 2, _today.AddDays(-8)); + + var result = await _service.GetRepairableGapDatesAsync(_user.Id, _today); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task WeeklyTwoOccurrenceGap_ReturnedByQuery_SucceedsWhenEchoedToRepair() + { + _habit = SetWeeklyHistory(priorOccurrences: 3, skipMostRecentCompletion: true); + _user.SetStreakState(14, 14, _today.AddDays(-15)); + _user.AwardStreakFreezeIfEligible(); + _user.SetStreakState(0, 14, null); + _users.GetByIdAsync(_user.Id, Arg.Any()).Returns(_user); + var flags = Substitute.For(); + flags.GetEnabledKeysForUserAsync(_user.Id, Arg.Any()).Returns(Array.Empty()); + var queryHandler = new GetStreakInfoQueryHandler(_users, _freezes, _dateService, _service, + flags, Substitute.For(), NullLogger.Instance); + + var info = await queryHandler.Handle(new GetStreakInfoQuery(_user.Id), CancellationToken.None); + + info.IsSuccess.Should().BeTrue(); + info.Value.RepairableGapDates.Should().Equal(_today.AddDays(-8), _today.AddDays(-1)); + var (repairHandler, _) = BuildRepairHandler(); + var repaired = await repairHandler.Handle( + new RepairStreakGapCommand(_user.Id, info.Value.RepairableGapDates!), + CancellationToken.None); + repaired.IsSuccess.Should().BeTrue(); + _persistedFreezes.Select(freeze => freeze.UsedOnDate) + .Should().Equal(_today.AddDays(-8), _today.AddDays(-1)); + } + /// /// A yearly gap is REFUSED, deliberately, and this pins why. Its predecessor sits 366 days back, /// outside the window the streak engine itself computes from. Accepting it on widened history was From f541c4b9e9979d1071115a94a2d2ead749c60d31 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 13 Sep 2026 18:05:54 -0300 Subject: [PATCH 3/3] chore(arch): regenerate the architecture map for the repairable gap read Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UFozk1UXf4Pm5spEHYxzT1 --- architecture.html | 2 +- architecture.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/architecture.html b/architecture.html index 801231fff..c8436f405 100644 --- a/architecture.html +++ b/architecture.html @@ -47,7 +47,7 @@

Handlers with no endpoint

RequestHandler file

Entities

EntityDomain file
- +