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
40 changes: 29 additions & 11 deletions src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel
if (args.TryGetProperty("limit", out var limitEl) && limitEl.ValueKind == JsonValueKind.Number)
limit = Math.Clamp(limitEl.GetInt32(), 1, 200);

var allHabits = await QueryHabitsAsync(userId, filters, includeMetrics, ct);
var allHabits = await QueryHabitsAsync(userId, filters, today, includeMetrics, ct);

var results = allHabits
.Where(h => h.ParentHabitId is null)
Expand Down Expand Up @@ -136,25 +136,37 @@ private static (DateOnly? Date, bool IncludeOverdue) ParseDateFilter(JsonElement
return (date, includeOverdue);
}

private async Task<IReadOnlyList<Habit>> QueryHabitsAsync(Guid userId, HabitFilters f, bool includeMetrics, CancellationToken ct)
private async Task<IReadOnlyList<Habit>> QueryHabitsAsync(
Guid userId,
HabitFilters f,
DateOnly today,
bool includeMetrics,
CancellationToken ct)
{
var normalizedSearch = NormalizeSearchValue(f.Search);
var normalizedTag = NormalizeSearchValue(f.Tag);

return await habitRepository.FindAsync(
var habits = await habitRepository.FindAsync(
h => h.UserId == userId
&& (f.IsCompleted == null ? !h.IsCompleted : h.IsCompleted == f.IsCompleted.Value)
&& (f.IsGeneral == null || h.IsGeneral == f.IsGeneral.Value)
&& (f.IsBadHabit == null || h.IsBadHabit == f.IsBadHabit.Value)
&& (!f.FrequencyOneTime || h.FrequencyUnit == null)
&& (f.Frequency == null || h.FrequencyUnit == f.Frequency.Value)
&& (!f.Date.HasValue || (!h.IsGeneral && (f.IncludeOverdue ? h.DueDate <= f.Date.Value : h.DueDate == f.Date.Value)))
&& (normalizedSearch == null || h.Title.Contains(normalizedSearch, StringComparison.OrdinalIgnoreCase))
&& (normalizedTag == null || h.Tags.Any(t => t.Name.Contains(normalizedTag, StringComparison.OrdinalIgnoreCase))),
includeMetrics
? q => q.Include(h => h.Tags).Include(h => h.Logs)
: q => q.Include(h => h.Tags),
q => includeMetrics
? q.Include(h => h.Tags).Include(h => h.Logs).AsSplitQuery()
: q
.Include(h => h.Tags)
.Include(h => h.Logs.Where(l => !l.IsDeleted && l.Date == today && l.Value > 0))
.AsSplitQuery(),
ct);

var expectedCompletion = f.IsCompleted ?? false;
return habits
.Where(h => GetResponseCompletion(h, today) == expectedCompletion)
.ToList();
}

private static string? NormalizeSearchValue(string? value)
Expand Down Expand Up @@ -197,11 +209,12 @@ private static string BuildHabitLine(Habit habit, DateOnly today, bool includeMe
private static List<string> BuildLabels(Habit habit, DateOnly today, bool includeMetrics)
{
var labels = new List<string>();
var isCompleted = GetResponseCompletion(habit, today);
if (habit.IsGeneral) labels.Add("GENERAL");
if (!habit.IsGeneral && !habit.IsCompleted && habit.DueDate < today) labels.Add("OVERDUE");
if (!habit.IsGeneral && !habit.IsCompleted && habit.DueDate == today) labels.Add("DUE TODAY");
if (!habit.IsGeneral && !isCompleted && habit.DueDate < today) labels.Add("OVERDUE");
if (!habit.IsGeneral && !isCompleted && habit.DueDate == today) labels.Add("DUE TODAY");
if (habit.IsBadHabit) labels.Add("BAD HABIT");
if (habit.IsCompleted) labels.Add("COMPLETED");
if (isCompleted) labels.Add("COMPLETED");
if (habit.Tags.Count > 0) labels.Add($"Tags: {string.Join(", ", habit.Tags.Select(t => t.Name))}");

AddMetricLabels(labels, habit, today, includeMetrics);
Expand All @@ -215,6 +228,11 @@ private static List<string> BuildLabels(Habit habit, DateOnly today, bool includ
return labels;
}

private static bool GetResponseCompletion(Habit habit, DateOnly today) =>
habit.IsGeneral
? HabitScheduleService.HasCompletedLogInRange(habit, today, today)
: habit.IsCompleted;

private static void AddMetricLabels(List<string> labels, Habit habit, DateOnly today, bool includeMetrics)
{
if (!includeMetrics) return;
Expand Down Expand Up @@ -250,7 +268,7 @@ private static void AppendChildren(StringBuilder sb, IReadOnlyList<Habit> allHab
{
var childLabels = new List<string>();
if (includeMetrics && child.Logs.Any(l => l.Date == today)) childLabels.Add("DONE");
if (child.IsCompleted) childLabels.Add("COMPLETED");
if (GetResponseCompletion(child, today)) childLabels.Add("COMPLETED");
var childLabelStr = childLabels.Count > 0 ? $" [{string.Join(" | ", childLabels)}]" : "";
var emojiLabel = string.IsNullOrWhiteSpace(child.Emoji) ? "No emoji" : $"Emoji: {child.Emoji}";
sb.AppendLine($"{indent}- \"{child.Title}\"{childSuffixes.GetValueOrDefault(child.Id, string.Empty)} | ID: {child.Id} | {emojiLabel}{childLabelStr}");
Expand Down
31 changes: 28 additions & 3 deletions src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ public async Task<Result<HabitDetailResponse>> Handle(GetHabitByIdQuery request,
userToday,
cancellationToken);
var children = HabitDetailChildMapper.MapChildren(habit, userToday, descendantLogsByHabitId);
var isCompleted = habit.IsCompleted;
if (habit.IsGeneral)
Comment thread
thomasluizon marked this conversation as resolved.
{
var currentDateLogs = await habitLogRepository.FindAsync(
l => l.HabitId == habit.Id
&& l.Date == userToday
&& l.Value > 0
&& !l.IsDeleted,
cancellationToken);
isCompleted = currentDateLogs.Count > 0;
}

return Result.Success(new HabitDetailResponse(
habit.Id,
Expand All @@ -87,7 +98,7 @@ public async Task<Result<HabitDetailResponse>> Handle(GetHabitByIdQuery request,
habit.FrequencyUnit,
habit.FrequencyQuantity,
habit.IsBadHabit,
habit.IsCompleted,
isCompleted,
habit.IsGeneral,
habit.IsFlexible,
habit.DueDate,
Expand Down Expand Up @@ -125,7 +136,7 @@ public static async Task<IReadOnlyDictionary<Guid, IReadOnlyCollection<HabitLog>
var descendantLogs = await habitLogRepository.FindAsync(
l => descendantIds.Contains(l.HabitId)
&& l.Date >= descendantLogCutoff
&& l.Date < userToday,
&& l.Date <= userToday,
cancellationToken);

return descendantLogs
Expand Down Expand Up @@ -164,7 +175,7 @@ private static HabitChildResponse MapChild(
child.FrequencyUnit,
child.FrequencyQuantity,
child.IsBadHabit,
child.IsCompleted,
GetResponseCompletion(child, userToday, descendantLogsByHabitId),
child.IsGeneral,
child.IsFlexible,
child.Days.ToList(),
Expand All @@ -178,6 +189,20 @@ private static HabitChildResponse MapChild(
MapChildren(child, userToday, descendantLogsByHabitId),
Emoji: child.Emoji);

private static bool GetResponseCompletion(
Habit habit,
DateOnly userToday,
IReadOnlyDictionary<Guid, IReadOnlyCollection<HabitLog>>? descendantLogsByHabitId)
{
if (!habit.IsGeneral)
return habit.IsCompleted;

return HabitScheduleService.HasCompletedLogInRange(
GetLogs(habit, descendantLogsByHabitId),
userToday,
userToday);
}

private static bool DetermineOverdueStatus(
Habit habit,
DateOnly userToday,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ public async Task<Result<HabitFullDetailResponse>> Handle(GetHabitFullDetailQuer
var allRootLogs = await habitLogRepository.FindAsync(
l => l.HabitId == request.HabitId,
cancellationToken);
var isCompleted = habit.IsGeneral
? allRootLogs.Any(l => !l.IsDeleted && l.Date == userToday && l.Value > 0)
: habit.IsCompleted;

var detail = new HabitDetailResponse(
habit.Id, habit.Title, habit.Description,
habit.FrequencyUnit, habit.FrequencyQuantity,
habit.IsBadHabit, habit.IsCompleted, habit.IsGeneral, habit.IsFlexible,
habit.IsBadHabit, isCompleted, habit.IsGeneral, habit.IsFlexible,
habit.DueDate, habit.DueTime, habit.DueEndTime, habit.EndDate,
habit.Days.ToList(), habit.Position,
habit.ReminderEnabled, habit.ReminderTimes, habit.ScheduledReminders,
Expand Down
25 changes: 17 additions & 8 deletions src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,14 @@
private async Task<Result<PaginatedResponse<HabitScheduleItem>>> HandleGeneralHabits(
GetHabitScheduleQuery request, CancellationToken cancellationToken)
{
var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);
var completionDate = GetCompletionDate(request, today);
var weekStartDay = await userDateService.GetUserWeekStartDayAsync(request.UserId, cancellationToken);

var allHabits = await habitRepository.FindAsync(
h => h.UserId == request.UserId && h.IsGeneral,
q => q.Include(h => h.Tags)

Check warning on line 151 in src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Call 'AsSplitQuery' to avoid multiplying rows by including 'Tags', 'Logs', and 'Goals' in the same query (a Cartesian explosion).
.Include(h => h.Logs)
.Include(h => h.Logs.Where(l => l.Date == completionDate))
.Include(h => h.Goals),
cancellationToken);

Expand All @@ -157,7 +159,7 @@
.OrderBy(h => h.Position ?? int.MaxValue)
.ThenBy(h => h.CreatedAtUtc);

topLevel = HabitScheduleFilters.ApplyCommonFilters(topLevel, request, lookup);
topLevel = HabitScheduleFilters.ApplyCommonFilters(topLevel, request, lookup, completionDate);

var filtered = topLevel.ToList();

Expand All @@ -170,6 +172,7 @@
weekStartDay,
IncludeAllChildren: true,
IncludeOverdue: request.IncludeOverdue,
UserToday: completionDate,
Search: request.Search);
var pagedItems = filtered
.Skip((page - 1) * request.PageSize)
Expand Down Expand Up @@ -259,7 +262,12 @@
.ToList();

if (request.IncludeGeneral)
await AppendGeneralHabits(pagedItems, request, logFrom, logTo, today, weekStartDay, cancellationToken);
await AppendGeneralHabits(
pagedItems,
request,
GetCompletionDate(request, today),
weekStartDay,
cancellationToken);

return Result.Success(new PaginatedResponse<HabitScheduleItem>(
pagedItems,
Expand Down Expand Up @@ -363,16 +371,14 @@
private async Task AppendGeneralHabits(
List<HabitScheduleItem> pagedItems,
GetHabitScheduleQuery request,
DateOnly logFrom,
DateOnly logTo,
DateOnly today,
DateOnly completionDate,
int weekStartDay,
CancellationToken cancellationToken)
{
var generalHabits = await habitRepository.FindAsync(
h => h.UserId == request.UserId && h.IsGeneral,
q => q.Include(h => h.Tags)

Check warning on line 380 in src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Call 'AsSplitQuery' to avoid multiplying rows by including 'Tags', 'Logs', and 'Goals' in the same query (a Cartesian explosion).
.Include(h => h.Logs.Where(l => l.Date >= logFrom && l.Date <= logTo))
.Include(h => h.Logs.Where(l => l.Date == completionDate))
.Include(h => h.Goals),
cancellationToken);

Expand All @@ -387,12 +393,15 @@
weekStartDay,
IncludeAllChildren: true,
IncludeOverdue: request.IncludeOverdue,
UserToday: today,
UserToday: completionDate,
Search: request.Search);
var generalItems = generalTopLevel
.Select(h => HabitScheduleFilters.MapToScheduleItem(h, [], false, ctx))
.ToList();

pagedItems.AddRange(generalItems);
}

private static DateOnly GetCompletionDate(GetHabitScheduleQuery request, DateOnly today) =>
request.DateFrom ?? request.DateTo ?? today;
}
22 changes: 18 additions & 4 deletions src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ private static bool DetermineOverdueStatus(Habit habit, DateOnly dateFrom, bool
internal static IEnumerable<Habit> ApplyCommonFilters(
IEnumerable<Habit> topLevel,
GetHabitScheduleQuery request,
ILookup<Guid?, Habit> lookup)
ILookup<Guid?, Habit> lookup,
DateOnly? userToday = null)
{
if (!string.IsNullOrWhiteSpace(request.Search))
topLevel = ApplySearchFilter(
Expand All @@ -114,7 +115,8 @@ internal static IEnumerable<Habit> ApplyCommonFilters(
lookup);

if (request.IsCompleted.HasValue)
topLevel = topLevel.Where(h => h.IsCompleted == request.IsCompleted.Value);
topLevel = topLevel.Where(h =>
GetResponseCompletion(h, userToday) == request.IsCompleted.Value);

if (request.TagIds is { Count: > 0 })
topLevel = ApplyTagFilter(topLevel, request.TagIds, lookup);
Expand Down Expand Up @@ -217,7 +219,7 @@ internal static HabitScheduleItem MapToScheduleItem(

return new HabitScheduleItem(
h.Id, h.Title, h.Description, h.FrequencyUnit, h.FrequencyQuantity,
h.IsBadHabit, h.IsCompleted, h.IsGeneral, h.IsFlexible,
h.IsBadHabit, GetResponseCompletion(h, ctx.UserToday), h.IsGeneral, h.IsFlexible,
h.Days.ToList(), h.Position, h.CreatedAtUtc,
h.DueDate, h.DueTime, h.DueEndTime, h.EndDate,
scheduledDates, isOverdue,
Expand Down Expand Up @@ -351,7 +353,8 @@ private static HabitScheduleChildItem MapSingleChild(Habit c, ScheduleMapContext

return new HabitScheduleChildItem(
c.Id, c.Title, c.Description,
c.FrequencyUnit, c.FrequencyQuantity, c.IsBadHabit, c.IsCompleted, c.IsGeneral, c.IsFlexible,
c.FrequencyUnit, c.FrequencyQuantity, c.IsBadHabit,
GetResponseCompletion(c, ctx.UserToday), c.IsGeneral, c.IsFlexible,
c.Days.ToList(), c.DueDate, c.DueTime, c.DueEndTime, c.EndDate,
scheduledDates, isOverdue,
c.Position, c.ChecklistItems, MapTags(c),
Expand All @@ -362,6 +365,17 @@ private static HabitScheduleChildItem MapSingleChild(Habit c, ScheduleMapContext
Emoji: c.Emoji);
}

private static bool GetResponseCompletion(Habit habit, DateOnly? userToday)
{
if (!habit.IsGeneral || !userToday.HasValue)
return habit.IsCompleted;

return HabitScheduleService.HasCompletedLogInRange(
habit,
userToday.Value,
userToday.Value);
}

/// <summary>
/// Whether a habit has a sub-habit worth navigating to: any incomplete one-time task or any
/// recurring/flexible child, regardless of today's schedule. A child that is a completed
Expand Down
10 changes: 8 additions & 2 deletions src/Orbit.Application/Habits/Services/HabitScheduleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,15 +266,21 @@ public static bool HasMissedPastOccurrence(Habit habit, DateOnly today)
}

/// <summary>
/// True when the habit has a completion log (Value &gt; 0) on any date within
/// True when the habit has an active completion log (Value &gt; 0) on any date within
/// [<paramref name="dateFrom"/>, <paramref name="dateTo"/>]. Skip logs (Value == 0) do not
/// count. This is the date-scoped "done in range" signal shared by the schedule query and the
/// daily summary — deliberately distinct from <see cref="Habit.IsCompleted"/>, which is a
/// sticky lifetime flag (a one-time task stays completed forever) and must never be used to
/// decide whether a habit was done on a particular day.
/// </summary>
public static bool HasCompletedLogInRange(Habit habit, DateOnly dateFrom, DateOnly dateTo) =>
habit.Logs.Any(l => l.Date >= dateFrom && l.Date <= dateTo && l.Value > 0);
HasCompletedLogInRange(habit.Logs, dateFrom, dateTo);

internal static bool HasCompletedLogInRange(
IEnumerable<HabitLog> logs,
DateOnly dateFrom,
DateOnly dateTo) =>
logs.Any(l => !l.IsDeleted && l.Date >= dateFrom && l.Date <= dateTo && l.Value > 0);

/// <summary>
/// True when the habit has an unresolved occurrence strictly before
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Orbit.Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="Hangfire.Core" Version="1.8.24" />
<PackageReference Include="MediatR" Version="14.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.22.0" />
Expand Down
10 changes: 7 additions & 3 deletions src/Orbit.Domain/Entities/Habit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ public static Result<Habit> Create(HabitCreateParams p)

public Result<HabitLog> Log(DateOnly date, string? note = null, bool advanceDueDate = true)
{
if (IsCompleted)
if (IsCompleted && !IsGeneral)
return Result.Failure<HabitLog>(DomainErrors.CannotLogCompletedHabit);

if (!IsBadHabit && !IsFlexible && _logs.Exists(l => l.Date == date && !l.IsDeleted))
Expand All @@ -175,11 +175,11 @@ public Result<HabitLog> Log(DateOnly date, string? note = null, bool advanceDueD
var log = HabitLog.Create(Id, date, 1, note);
_logs.Add(log);

if (FrequencyUnit is null)
if (FrequencyUnit is null && !IsGeneral)
{
IsCompleted = true;
Comment thread
thomasluizon marked this conversation as resolved.
Comment thread
thomasluizon marked this conversation as resolved.
Comment thread
thomasluizon marked this conversation as resolved.
}
else if (!IsFlexible && advanceDueDate)
else if (FrequencyUnit is not null && !IsFlexible && advanceDueDate)
{
AdvanceDueDate(date);

Expand Down Expand Up @@ -379,7 +379,11 @@ private void ApplyRequiredUpdates(HabitUpdateParams p)
private void ApplyOptionalUpdates(HabitUpdateParams p)
{
if (p.IsGeneral.HasValue)
{
IsGeneral = p.IsGeneral.Value;
if (IsGeneral)
IsCompleted = false;
}
if (p.IsFlexible.HasValue)
IsFlexible = p.IsFlexible.Value;
if (p.ReminderEnabled.HasValue)
Expand Down
Loading
Loading