Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (`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.
2 changes: 2 additions & 0 deletions src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ public static class AppConstants
public const int MaxStreakFreezesAccumulated = 3;
public const int StreakDaysPerFreeze = 7;
public const int MaxStreakLookbackDays = 365;
/// <summary>Two annual intervals retain a completed predecessor while a repaired yearly occurrence remains current.</summary>
public const int StreakRepairWindowDays = 2 * 366;
public const int MaxSyncWindowDays = 30;
public const int SyncCleanupMarginDays = 1;
public const int MaxChallengeTitleLength = 200;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task<Result<StreakHistoryResponse>> Handle(GetStreakHistoryQuery re
if (!unlocked)
return Result.PayGateFailure<StreakHistoryResponse>("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
Expand Down
12 changes: 10 additions & 2 deletions src/Orbit.Application/Habits/Services/HabitScheduleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,16 @@
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;
}
Expand Down Expand Up @@ -315,8 +323,8 @@

private static DateOnly Max(DateOnly first, DateOnly second, DateOnly third) =>
first > second
? first > third ? first : third

Check warning on line 326 in src/Orbit.Application/Habits/Services/HabitScheduleService.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Extract this nested ternary operation into an independent statement.
: second > third ? second : third;

Check warning on line 327 in src/Orbit.Application/Habits/Services/HabitScheduleService.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Extract this nested ternary operation into an independent statement.

public static bool HasMissedPastOccurrence(
Habit habit,
Expand Down
10 changes: 5 additions & 5 deletions src/Orbit.Infrastructure/Services/UserStreakService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand All @@ -116,7 +116,7 @@ public async Task<IReadOnlyList<DateOnly>> 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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -321,7 +321,7 @@ private async Task<UserStreakState> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken>()).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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]
Expand All @@ -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()
Expand Down Expand Up @@ -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);
Expand All @@ -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()
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using FluentValidation.TestHelper;
using Orbit.Application.Common;
using Orbit.Application.Gamification.Commands;
using Orbit.Application.Gamification.Validators;

Expand Down Expand Up @@ -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);
}
}
Loading
Loading