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
1 change: 1 addition & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
builder.Services.AddScoped<ISocialGraphReader, SocialGraphReader>();
builder.Services.AddScoped<IHabitLogReader, HabitLogReader>();
builder.Services.AddScoped<IHabitScheduleLogReader, HabitScheduleLogReader>();
builder.Services.AddScoped<IHabitSummaryLogReader, HabitSummaryLogReader>();
builder.Services.AddScoped<IHabitSchedulePageLoader, HabitSchedulePageLoader>();
builder.Services.AddScoped<Orbit.Application.Challenges.Services.IChallengeProgressService, Orbit.Application.Challenges.Services.ChallengeProgressService>();
builder.Services.AddScoped<Orbit.Application.Challenges.Services.ChallengeProgressRepositories>(sp =>
Expand Down
86 changes: 70 additions & 16 deletions src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
DateOnly DateTo,
string Language) : IRequest<Result<DailySummaryResponse>>;

public record HabitSummarySlipDate(Guid HabitId, DateOnly Date);

public class GetDailySummaryQueryHandler(
IGenericRepository<Habit> habitRepository,
IGenericRepository<User> userRepository,
IGenericRepository<HabitLog> habitLogRepository,
IHabitSummaryLogReader summaryLogReader,
IPayGateService payGate,
ISummaryService summaryService,
IMemoryCache cache) : IRequestHandler<GetDailySummaryQuery, Result<DailySummaryResponse>>
Expand All @@ -47,7 +50,7 @@

string effectiveLanguage;
if (!string.IsNullOrWhiteSpace(user.Language))
effectiveLanguage = user.Language!;

Check warning on line 53 in src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
else if (!string.IsNullOrWhiteSpace(request.Language))
effectiveLanguage = request.Language;
else
Expand All @@ -72,25 +75,27 @@
return Result.Success(new DailySummaryResponse(cached.Summary, string.Empty, FromCache: true));
}

var overdueLogFrom = userToday.AddDays(-AppConstants.MaxRangeDays);
var logFrom = request.DateFrom < overdueLogFrom ? request.DateFrom : overdueLogFrom;
var logTo = request.DateTo > userToday ? request.DateTo : userToday;
var habits = await habitRepository.FindAsync(
h => h.UserId == request.UserId && !h.IsGeneral,
q => q
.Include(h => h.Logs.Where(l => l.Date >= logFrom && l.Date <= logTo))
.Include(h => h.Goals)
.AsSplitQuery(),
q => q.Include(h => h.Goals),
cancellationToken);

var windows = BuildLogWindows(habits, request.DateFrom, request.DateTo, userToday, user.WeekStartDay);
var logFacts = await summaryLogReader.ReadAsync(windows, cancellationToken);
var logsByHabit = logFacts
.GroupBy(log => log.HabitId)
.ToDictionary(group => group.Key, group => group
.Select(log => HabitLog.FromSummaryRead(log.HabitId, log.Date, log.Value)).ToList());
foreach (var habit in habits)
habit.LoadScheduleLogsForRead(logsByHabit.GetValueOrDefault(habit.Id) ?? []);

var summaryHabits = habits
.Where(h => !HasSkipLogInRange(h, request.DateFrom, request.DateTo))
.ToList();
var dueDateResolution = await HabitDueDateResolutionLoader.LoadAsync(
habitLogRepository,
summaryHabits,
logFrom,
cancellationToken);
var dueDateResolution = summaryHabits
.Where(habit => habit.Logs.Any(log => log.Date == habit.DueDate && log.Value >= 0))
.Select(habit => habit.Id)
.ToHashSet();

var lastBadHabitSlipDates = await LoadLastBadHabitSlipDates(
summaryHabits, userToday, cancellationToken);
Expand Down Expand Up @@ -131,20 +136,69 @@
private static bool HasSkipLogInRange(Habit habit, DateOnly dateFrom, DateOnly dateTo) =>
habit.Logs.Any(l => l.Date >= dateFrom && l.Date <= dateTo && l.Value == 0);

private static IReadOnlyList<HabitSummaryLogWindow> BuildLogWindows(

Check warning on line 139 in src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.

Check failure on line 139 in src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 31 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaDfna0rxjvAjhH7u2MJ&open=AaDfna0rxjvAjhH7u2MJ&pullRequest=599

Check warning on line 139 in src/Orbit.Application/Habits/Queries/GetDailySummaryQuery.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change return type of method 'BuildLogWindows' from 'System.Collections.Generic.IReadOnlyList<Orbit.Domain.Interfaces.HabitSummaryLogWindow>' to 'System.Collections.Generic.List<Orbit.Domain.Interfaces.HabitSummaryLogWindow>' for improved performance

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaDfna0rxjvAjhH7u2MK&open=AaDfna0rxjvAjhH7u2MK&pullRequest=599
IEnumerable<Habit> habits, DateOnly dateFrom, DateOnly dateTo, DateOnly userToday, int weekStartDay)
{
var result = new List<HabitSummaryLogWindow>();
foreach (var habit in habits)
{
var windows = new List<HabitSummaryLogWindow>
{
new(habit.Id, dateFrom, dateTo)
};
if (!habit.IsBadHabit && !habit.IsFlexible && !habit.IsCompleted
&& habit.FrequencyUnit is not null && habit.DueDate < userToday
&& !HabitScheduleService.IsHabitDueOnDate(habit, userToday, weekStartDay))
{
var latest = userToday.AddDays(-1);
if (habit.EndDate.HasValue && habit.EndDate.Value < latest)
latest = habit.EndDate.Value;

if (latest >= habit.DueDate)
{
var earliest = latest.AddDays(-Math.Min(AppConstants.MaxRangeDays - 1, latest.DayNumber));
var from = habit.DueDate > earliest ? habit.DueDate : earliest;
windows.AddRange(HabitScheduleService.GetScheduledDates(habit, from, latest, weekStartDay)
.Select(date => new HabitSummaryLogWindow(habit.Id, date, date)));
}

if ((!habit.EndDate.HasValue || habit.DueDate <= habit.EndDate.Value)
&& HabitScheduleService.IsHabitDueOnDate(habit, habit.DueDate, weekStartDay))
windows.Add(new HabitSummaryLogWindow(habit.Id, habit.DueDate, habit.DueDate));
}

foreach (var window in windows.OrderBy(window => window.From))
{
if (result.Count > 0 && result[^1].HabitId == habit.Id
&& window.From.DayNumber <= result[^1].To.DayNumber + 1)
{
var previous = result[^1];
result[^1] = previous with { To = window.To > previous.To ? window.To : previous.To };
}
else
{
result.Add(window);
}
}
}

return result;
}

private async Task<IReadOnlyDictionary<Guid, DateOnly>> LoadLastBadHabitSlipDates(
IReadOnlyList<Habit> habits, DateOnly userToday, CancellationToken cancellationToken)
{
var badHabitIds = habits.Where(h => h.IsBadHabit).Select(h => h.Id).ToHashSet();
if (badHabitIds.Count == 0)
return new Dictionary<Guid, DateOnly>();

var slipLogs = await habitLogRepository.FindAsync(
var slipDates = await habitLogRepository.ProjectAsync(
l => badHabitIds.Contains(l.HabitId) && l.Value > 0 && l.Date <= userToday,
query => query.GroupBy(log => log.HabitId)
.Select(group => new HabitSummarySlipDate(group.Key, group.Max(log => log.Date))),
cancellationToken);

return slipLogs
.GroupBy(l => l.HabitId)
.ToDictionary(g => g.Key, g => g.Max(l => l.Date));
return slipDates.ToDictionary(row => row.HabitId, row => row.Date);
}

private static string SummaryTimeBucket(TimeOnly? currentLocalTime)
Expand Down
8 changes: 8 additions & 0 deletions src/Orbit.Domain/Entities/HabitLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ public class HabitLog : Entity, ITimestamped, ISoftDeletable

private HabitLog() { }

public static HabitLog FromSummaryRead(Guid habitId, DateOnly date, decimal value)
{
if (habitId == Guid.Empty)
throw new ArgumentException("Summary log habit id must be set.");

return new HabitLog { HabitId = habitId, Date = date, Value = value };
}

public static HabitLog FromScheduleRead(
Guid id,
Guid habitId,
Expand Down
12 changes: 12 additions & 0 deletions src/Orbit.Domain/Interfaces/IHabitSummaryLogReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Orbit.Domain.Interfaces;

public sealed record HabitSummaryLogWindow(Guid HabitId, DateOnly From, DateOnly To);

public sealed record HabitSummaryLogFact(Guid HabitId, DateOnly Date, decimal Value);

public interface IHabitSummaryLogReader
{
Task<IReadOnlyList<HabitSummaryLogFact>> ReadAsync(
IReadOnlyCollection<HabitSummaryLogWindow> windows,
CancellationToken cancellationToken = default);
}
56 changes: 56 additions & 0 deletions src/Orbit.Infrastructure/Persistence/HabitSummaryLogReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Orbit.Domain.Interfaces;

namespace Orbit.Infrastructure.Persistence;

public sealed class HabitSummaryLogReader(OrbitDbContext context) : IHabitSummaryLogReader
{
public async Task<IReadOnlyList<HabitSummaryLogFact>> ReadAsync(
IReadOnlyCollection<HabitSummaryLogWindow> windows,
CancellationToken cancellationToken = default)
{
if (windows.Count == 0)
return [];

var windowsJson = JsonSerializer.Serialize(windows);
FormattableString sql = context.Database.ProviderName switch
{
"Microsoft.EntityFrameworkCore.Sqlite" => $"""
SELECT DISTINCT l."HabitId", l."Date", l."Value"
FROM "HabitLogs" AS l
JOIN json_each({windowsJson}) AS w
ON l."HabitId" = upper(json_extract(w.value, '$.HabitId'))
AND l."Date" >= json_extract(w.value, '$.From')
AND l."Date" <= json_extract(w.value, '$.To')
WHERE NOT l."IsDeleted" AND l."Value" >= 0
""",
"Npgsql.EntityFrameworkCore.PostgreSQL" => $"""
SELECT DISTINCT l."HabitId", l."Date"::text AS "Date", l."Value"
FROM "HabitLogs" AS l
JOIN jsonb_to_recordset(CAST({windowsJson} AS jsonb))
AS w("HabitId" uuid, "From" date, "To" date)
ON l."HabitId" = w."HabitId"
AND l."Date" >= w."From"
AND l."Date" <= w."To"
WHERE NOT l."IsDeleted" AND l."Value" >= 0
""",
_ => throw new NotSupportedException("Summary log windows require a relational provider.")
};

var rows = await context.Database.SqlQuery<HabitSummaryLogRow>(sql)
.ToListAsync(cancellationToken);
return rows.Select(row => new HabitSummaryLogFact(
row.HabitId,
DateOnly.Parse(row.Date, CultureInfo.InvariantCulture),
row.Value)).ToList();
}

public sealed class HabitSummaryLogRow
{
public Guid HabitId { get; set; }
public string Date { get; set; } = string.Empty;
public decimal Value { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class GetDailySummaryQueryHandlerTests
private readonly IGenericRepository<Habit> _habitRepo = Substitute.For<IGenericRepository<Habit>>();
private readonly IGenericRepository<User> _userRepo = Substitute.For<IGenericRepository<User>>();
private readonly IGenericRepository<HabitLog> _habitLogRepo = Substitute.For<IGenericRepository<HabitLog>>();
private readonly IHabitSummaryLogReader _summaryLogReader = Substitute.For<IHabitSummaryLogReader>();
private readonly IPayGateService _payGate = Substitute.For<IPayGateService>();
private readonly ISummaryService _summaryService = Substitute.For<ISummaryService>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
Expand All @@ -29,8 +30,10 @@ public class GetDailySummaryQueryHandlerTests

public GetDailySummaryQueryHandlerTests()
{
_summaryLogReader.ReadAsync(Arg.Any<IReadOnlyCollection<HabitSummaryLogWindow>>(),
Arg.Any<CancellationToken>()).Returns(Array.Empty<HabitSummaryLogFact>());
_handler = new GetDailySummaryQueryHandler(
_habitRepo, _userRepo, _habitLogRepo, _payGate, _summaryService, _cache);
_habitRepo, _userRepo, _habitLogRepo, _summaryLogReader, _payGate, _summaryService, _cache);
}

private static User CreateTestUser()
Expand Down Expand Up @@ -68,7 +71,7 @@ public async Task Handle_GeneratesNewSummary_WhenNotCached()
}

[Fact]
public async Task Handle_MultipleCollectionIncludes_UsesSplitQuery()
public async Task Handle_HabitLoad_IncludesGoalsWithoutFullLogs()
{
Func<IQueryable<Habit>, IQueryable<Habit>>? queryBuilder = null;
var user = CreateTestUser();
Expand All @@ -95,7 +98,7 @@ await _handler.Handle(
await using var context = new OrbitDbContext(options);
var shapedQuery = queryBuilder!(context.Habits);

shapedQuery.Expression.ToString().Should().Contain(nameof(RelationalQueryableExtensions.AsSplitQuery));
shapedQuery.Expression.ToString().Should().Contain("Goals").And.NotContain("Logs");
}

[Fact]
Expand Down Expand Up @@ -141,6 +144,9 @@ public async Task Handle_ExcludesHabitsSkippedInRequestedRange()
var skipped = Habit.Create(new HabitCreateParams(
UserId, "Flexible workout", FrequencyUnit.Week, 3, DueDate: Today, IsFlexible: true)).Value;
skipped.SkipFlexible(Today);
_summaryLogReader.ReadAsync(Arg.Any<IReadOnlyCollection<HabitSummaryLogWindow>>(),
Arg.Any<CancellationToken>()).Returns(
[new HabitSummaryLogFact(skipped.Id, Today, 0)]);

_habitRepo.FindAsync(
Arg.Any<Expression<Func<Habit, bool>>>(),
Expand Down Expand Up @@ -183,10 +189,11 @@ public async Task Handle_BadHabitSlippedBeforeWindow_PassesLastSlipDateFromTarge
.Returns(new List<Habit> { badHabit }.AsReadOnly());

var slipDate = Today.AddDays(-3);
_habitLogRepo.FindAsync(
_habitLogRepo.ProjectAsync(
Arg.Any<Expression<Func<HabitLog, bool>>>(),
Arg.Any<Func<IQueryable<HabitLog>, IQueryable<HabitSummarySlipDate>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<HabitLog> { HabitLog.Create(badHabit.Id, slipDate, 1) }.AsReadOnly());
.Returns(new List<HabitSummarySlipDate> { new(badHabit.Id, slipDate) }.AsReadOnly());

_summaryService.GenerateSummaryAsync(
Arg.Any<IEnumerable<Habit>>(),
Expand Down Expand Up @@ -222,11 +229,14 @@ public async Task Handle_BadHabitSlipReadFilter_ExcludesLogsFromHabitsTheUserDoe
.Returns(new List<Habit> { badHabit }.AsReadOnly());

Expression<Func<HabitLog, bool>>? readFilter = null;
_habitLogRepo.FindAsync(Arg.Any<Expression<Func<HabitLog, bool>>>(), Arg.Any<CancellationToken>())
_habitLogRepo.ProjectAsync(
Arg.Any<Expression<Func<HabitLog, bool>>>(),
Arg.Any<Func<IQueryable<HabitLog>, IQueryable<HabitSummarySlipDate>>>(),
Arg.Any<CancellationToken>())
.Returns(call =>
{
readFilter = call.Arg<Expression<Func<HabitLog, bool>>>();
return (IReadOnlyList<HabitLog>)new List<HabitLog>();
return (IReadOnlyList<HabitSummarySlipDate>)[];
});

_summaryService.GenerateSummaryAsync(
Expand Down Expand Up @@ -269,8 +279,9 @@ public async Task Handle_NoBadHabits_SkipsSlipQueryAndPassesEmptyMap()

await _handler.Handle(query, CancellationToken.None);

await _habitLogRepo.DidNotReceive().FindAsync(
await _habitLogRepo.DidNotReceive().ProjectAsync(
Arg.Any<Expression<Func<HabitLog, bool>>>(),
Arg.Any<Func<IQueryable<HabitLog>, IQueryable<HabitSummarySlipDate>>>(),
Arg.Any<CancellationToken>());
await _summaryService.Received(1).GenerateSummaryAsync(
Arg.Any<IEnumerable<Habit>>(),
Expand Down Expand Up @@ -450,7 +461,7 @@ public async Task Handle_CacheExpiry_UsesUserTimezoneEndOfDay()
{
var capturingCache = new CapturingCache();
var handler = new GetDailySummaryQueryHandler(
_habitRepo, _userRepo, _habitLogRepo, _payGate, _summaryService, capturingCache);
_habitRepo, _userRepo, _habitLogRepo, _summaryLogReader, _payGate, _summaryService, capturingCache);

var user = CreateTestUser();
user.SetTimeZone("America/Sao_Paulo");
Expand Down
Loading
Loading