Skip to content
Closed
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
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -8074,6 +8074,7 @@
"GetStreakInfoQueryHandler",
"Habit",
"HabitLog",
"RepairStreakGapCommand",
"RepairStreakGapCommandHandler",
"StreakFreeze",
"User"
Expand Down
10 changes: 10 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -15369,6 +15369,16 @@
"string"
],
"format": "int32"
},
"repairableGapDates": {
"type": [
"null",
"array"
],
"items": {
"type": "string",
"format": "date"
}
}
}
},
Expand Down
10 changes: 8 additions & 2 deletions src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public record StreakInfoResponse(
DateOnly? RepairDate,
int RepairsRemainingThisMonth,
DateOnly? LastFreezeCoveredDate = null,
int? FreezeBankRemaining = null);
int? FreezeBankRemaining = null,
IReadOnlyList<DateOnly>? RepairableGapDates = null);

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

Expand Down Expand Up @@ -94,6 +95,10 @@ public async Task<Result<StreakInfoResponse>> 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)
{
Expand Down Expand Up @@ -138,6 +143,7 @@ public async Task<Result<StreakInfoResponse>> Handle(GetStreakInfoQuery request,
repairDate,
freezesAvailableToUse,
lastFreezeCoveredDate,
freezeBankRemaining));
freezeBankRemaining,
repairableGapDates));
}
}
5 changes: 5 additions & 0 deletions src/Orbit.Domain/Interfaces/IUserStreakService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ namespace Orbit.Domain.Interfaces;

public interface IUserStreakService
{
Task<IReadOnlyList<DateOnly>> GetRepairableGapDatesAsync(
Guid userId,
DateOnly userToday,
CancellationToken cancellationToken = default);

Task<UserStreakState?> EvaluateGapRepairAsync(
Guid userId,
DateOnly userToday,
Expand Down
147 changes: 112 additions & 35 deletions src/Orbit.Infrastructure/Services/UserStreakService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,39 @@ public class UserStreakService(
freezeDateSet);
}

public async Task<IReadOnlyList<DateOnly>> 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PreGapLastActiveDate is the first saved break, not necessarily the predecessor of the terminal gap being offered. After an older gap is left unrepaired, a restarted run followed by a new gap makes this slice begin at the old break, include intervening completions, and return [] even though the command accepts the newer terminal gap when the bank is sufficient.

Technical details
# Derive the latest terminal gap

## Affected sites
- `src/Orbit.Infrastructure/Services/UserStreakService.cs:104` selects the persistent first-break snapshot.
- `src/Orbit.Domain/Entities/User.cs:582` intentionally retains that snapshot across later decreases until repair or account reset.

## Required outcome
- Return the newest terminal scheduled gap that the repair command would accept, even when an older snapshot remains.
- Preserve the completion-today and sparse-schedule behavior.

## Suggested approach
- Walk backward from `gapEndIndex` over uncovered scheduled dates to the nearest covered scheduled predecessor instead of unconditionally anchoring to `PreGapLastActiveDate`.
- Add a regression with an old unrepaired gap, a restarted completed run, and a newer terminal gap whose exact returned dates succeed through `RepairStreakGapCommand`.

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<UserStreakState?> EvaluateGapRepairAsync(
Guid userId,
DateOnly userToday,
Expand All @@ -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<GapRepairContext?> 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);
Expand All @@ -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<DateOnly> 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;

Expand All @@ -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<DateOnly>(freezes);
context.ExpectedDates,
context.Completions,
context.Freezes,
context.LookbackStart,
userToday);
var repairedDates = new HashSet<DateOnly>(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<DateOnly> ExpectedDates,
DateOnly[] ScheduledDates,
HashSet<DateOnly> Completions,
HashSet<DateOnly> Freezes);

internal static StreakRepairEvaluation EvaluateRepair(
User user,
DateOnly userToday,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public GetStreakInfoQueryHandlerTests()
Today.AddDays(-1),
Arg.Any<CancellationToken>())
.Returns(StreakRepairEvaluation.Unavailable(Today.AddDays(-1)));
_userStreakService.GetRepairableGapDatesAsync(
UserId,
Today,
Arg.Any<CancellationToken>())
.Returns(Array.Empty<DateOnly>());
}

private static User CreateTestUser()
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -269,13 +275,19 @@ 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<CancellationToken>())
.Returns([Today.AddDays(-2), Today.AddDays(-1)]);

var result = await _handler.Handle(new GetStreakInfoQuery(UserId), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
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",
Expand Down
Loading
Loading