diff --git a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs index 1b67d98d..21058366 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/QueryHabitsTool.cs @@ -65,7 +65,7 @@ public async Task 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) @@ -136,14 +136,18 @@ private static (DateOnly? Date, bool IncludeOverdue) ParseDateFilter(JsonElement return (date, includeOverdue); } - private async Task> QueryHabitsAsync(Guid userId, HabitFilters f, bool includeMetrics, CancellationToken ct) + private async Task> 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) @@ -151,10 +155,18 @@ private async Task> QueryHabitsAsync(Guid userId, HabitFilt && (!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) @@ -197,11 +209,12 @@ private static string BuildHabitLine(Habit habit, DateOnly today, bool includeMe private static List BuildLabels(Habit habit, DateOnly today, bool includeMetrics) { var labels = new List(); + 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); @@ -215,6 +228,11 @@ private static List 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 labels, Habit habit, DateOnly today, bool includeMetrics) { if (!includeMetrics) return; @@ -250,7 +268,7 @@ private static void AppendChildren(StringBuilder sb, IReadOnlyList allHab { var childLabels = new List(); 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}"); diff --git a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs index 8b0592a6..c7f2a504 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs @@ -79,6 +79,17 @@ public async Task> Handle(GetHabitByIdQuery request, userToday, cancellationToken); var children = HabitDetailChildMapper.MapChildren(habit, userToday, descendantLogsByHabitId); + var isCompleted = habit.IsCompleted; + if (habit.IsGeneral) + { + 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, @@ -87,7 +98,7 @@ public async Task> Handle(GetHabitByIdQuery request, habit.FrequencyUnit, habit.FrequencyQuantity, habit.IsBadHabit, - habit.IsCompleted, + isCompleted, habit.IsGeneral, habit.IsFlexible, habit.DueDate, @@ -125,7 +136,7 @@ public static async Task var descendantLogs = await habitLogRepository.FindAsync( l => descendantIds.Contains(l.HabitId) && l.Date >= descendantLogCutoff - && l.Date < userToday, + && l.Date <= userToday, cancellationToken); return descendantLogs @@ -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(), @@ -178,6 +189,20 @@ private static HabitChildResponse MapChild( MapChildren(child, userToday, descendantLogsByHabitId), Emoji: child.Emoji); + private static bool GetResponseCompletion( + Habit habit, + DateOnly userToday, + IReadOnlyDictionary>? descendantLogsByHabitId) + { + if (!habit.IsGeneral) + return habit.IsCompleted; + + return HabitScheduleService.HasCompletedLogInRange( + GetLogs(habit, descendantLogsByHabitId), + userToday, + userToday); + } + private static bool DetermineOverdueStatus( Habit habit, DateOnly userToday, diff --git a/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs index a255ed30..99937880 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs @@ -55,11 +55,14 @@ public async Task> 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, diff --git a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs index 53e0c6f5..b84bd067 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs @@ -142,12 +142,14 @@ public async Task>> Handle(GetHabitS private async Task>> 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) - .Include(h => h.Logs) + .Include(h => h.Logs.Where(l => l.Date == completionDate)) .Include(h => h.Goals), cancellationToken); @@ -157,7 +159,7 @@ private async Task>> HandleGeneralHa .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(); @@ -170,6 +172,7 @@ private async Task>> HandleGeneralHa weekStartDay, IncludeAllChildren: true, IncludeOverdue: request.IncludeOverdue, + UserToday: completionDate, Search: request.Search); var pagedItems = filtered .Skip((page - 1) * request.PageSize) @@ -259,7 +262,12 @@ private async Task>> HandleScheduled .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( pagedItems, @@ -363,16 +371,14 @@ private async Task>> BuildNonDateRes private async Task AppendGeneralHabits( List 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) - .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); @@ -387,7 +393,7 @@ private async Task AppendGeneralHabits( weekStartDay, IncludeAllChildren: true, IncludeOverdue: request.IncludeOverdue, - UserToday: today, + UserToday: completionDate, Search: request.Search); var generalItems = generalTopLevel .Select(h => HabitScheduleFilters.MapToScheduleItem(h, [], false, ctx)) @@ -395,4 +401,7 @@ private async Task AppendGeneralHabits( pagedItems.AddRange(generalItems); } + + private static DateOnly GetCompletionDate(GetHabitScheduleQuery request, DateOnly today) => + request.DateFrom ?? request.DateTo ?? today; } diff --git a/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs index 1cf5f548..fa10232c 100644 --- a/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs +++ b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs @@ -102,7 +102,8 @@ private static bool DetermineOverdueStatus(Habit habit, DateOnly dateFrom, bool internal static IEnumerable ApplyCommonFilters( IEnumerable topLevel, GetHabitScheduleQuery request, - ILookup lookup) + ILookup lookup, + DateOnly? userToday = null) { if (!string.IsNullOrWhiteSpace(request.Search)) topLevel = ApplySearchFilter( @@ -114,7 +115,8 @@ internal static IEnumerable 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); @@ -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, @@ -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), @@ -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); + } + /// /// 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 diff --git a/src/Orbit.Application/Habits/Services/HabitScheduleService.cs b/src/Orbit.Application/Habits/Services/HabitScheduleService.cs index dbdfcd3e..ba20c3d8 100644 --- a/src/Orbit.Application/Habits/Services/HabitScheduleService.cs +++ b/src/Orbit.Application/Habits/Services/HabitScheduleService.cs @@ -266,7 +266,7 @@ public static bool HasMissedPastOccurrence(Habit habit, DateOnly today) } /// - /// True when the habit has a completion log (Value > 0) on any date within + /// True when the habit has an active completion log (Value > 0) on any date within /// [, ]. 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 , which is a @@ -274,7 +274,13 @@ public static bool HasMissedPastOccurrence(Habit habit, DateOnly today) /// decide whether a habit was done on a particular day. /// 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 logs, + DateOnly dateFrom, + DateOnly dateTo) => + logs.Any(l => !l.IsDeleted && l.Date >= dateFrom && l.Date <= dateTo && l.Value > 0); /// /// True when the habit has an unresolved occurrence strictly before diff --git a/src/Orbit.Application/Orbit.Application.csproj b/src/Orbit.Application/Orbit.Application.csproj index d1bc5def..29ac12e1 100644 --- a/src/Orbit.Application/Orbit.Application.csproj +++ b/src/Orbit.Application/Orbit.Application.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index 1ec8ae60..9119f09c 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -166,7 +166,7 @@ public static Result Create(HabitCreateParams p) public Result Log(DateOnly date, string? note = null, bool advanceDueDate = true) { - if (IsCompleted) + if (IsCompleted && !IsGeneral) return Result.Failure(DomainErrors.CannotLogCompletedHabit); if (!IsBadHabit && !IsFlexible && _logs.Exists(l => l.Date == date && !l.IsDeleted)) @@ -175,11 +175,11 @@ public Result 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; } - else if (!IsFlexible && advanceDueDate) + else if (FrequencyUnit is not null && !IsFlexible && advanceDueDate) { AdvanceDueDate(date); @@ -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) diff --git a/src/Orbit.Infrastructure/Migrations/20260806120000_ClearGeneralHabitCompletionFlags.cs b/src/Orbit.Infrastructure/Migrations/20260806120000_ClearGeneralHabitCompletionFlags.cs new file mode 100644 index 00000000..d6cf503b --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260806120000_ClearGeneralHabitCompletionFlags.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations; + +[DbContext(typeof(OrbitDbContext))] +[Migration("20260806120000_ClearGeneralHabitCompletionFlags")] +public class ClearGeneralHabitCompletionFlags : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE "Habits" + SET "IsCompleted" = false + WHERE "IsGeneral" = true + AND "IsCompleted" = true; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE "Habits" h + SET "IsCompleted" = true + WHERE h."IsGeneral" = true + AND h."IsCompleted" = false + AND EXISTS ( + SELECT 1 + FROM "HabitLogs" l + WHERE l."HabitId" = h."Id" + AND l."Value" > 0 + AND l."IsDeleted" = false + ); + """); + } +} diff --git a/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs b/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs index 47be0115..ce116681 100644 --- a/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/QueryHabitsToolTests.cs @@ -40,14 +40,15 @@ public async Task NoFilters_ReturnsAllActiveParentHabits() var h1 = CreateHabit("Water", FrequencyUnit.Day, 1, dueDate: Today, position: 0); var h2 = CreateHabit("Read", FrequencyUnit.Week, 1, dueDate: Today, position: 1); var completed = CreateHabit("Done", null, null, dueDate: Today); - completed.Log(Today); SetupHabits(h1, h2, completed); + completed.Log(Today); SetupHabits(h1, h2, completed); var result = await Execute("{}"); result.Success.Should().BeTrue(); result.EntityName.Should().Contain("Water"); result.EntityName.Should().Contain("Read"); - result.EntityName.Should().NotContain("Done"); } + result.EntityName.Should().NotContain("Done"); + } [Fact] public async Task NoFilters_IncludesEmojiState() @@ -195,7 +196,7 @@ public async Task IsCompletedTrue_ReturnsCompletedHabits() { var active = CreateHabit("Active", FrequencyUnit.Day, 1, dueDate: Today); var done = CreateHabit("Done", null, null, dueDate: Today); - done.Log(Today); SetupHabits(active, done); + done.Log(Today); SetupHabits(active, done); var result = await Execute("""{"is_completed": true}"""); @@ -203,12 +204,48 @@ public async Task IsCompletedTrue_ReturnsCompletedHabits() result.EntityName.Should().NotContain("Active"); } + [Fact] + public async Task IsCompletedTrue_ReturnsGeneralHabitLoggedTodayWithCompletedLabel() + { + var completedToday = CreateHabit( + "Read someday", + null, + null, + dueDate: Today, + isGeneral: true); + completedToday.Log(Today).IsSuccess.Should().BeTrue(); + SetupHabits(completedToday); + + var result = await Execute("""{"is_completed": true}"""); + + result.EntityName.Should().Contain("Read someday"); + result.EntityName.Should().Contain("[GENERAL | COMPLETED]"); + } + + [Fact] + public async Task IsCompletedTrue_ExcludesGeneralHabitLoggedBeforeToday() + { + var completedYesterday = CreateHabit( + "Read yesterday", + null, + null, + dueDate: Today, + isGeneral: true); + completedYesterday.Log(Today.AddDays(-1)).IsSuccess.Should().BeTrue(); + SetupHabits(completedYesterday); + + var result = await Execute("""{"is_completed": true}"""); + + result.EntityName.Should().Contain("No habits found"); + result.EntityName.Should().NotContain("Read yesterday"); + } + [Fact] public async Task DefaultExcludesCompleted() { var active = CreateHabit("Active", FrequencyUnit.Day, 1, dueDate: Today); var done = CreateHabit("Done", null, null, dueDate: Today); - done.Log(Today); SetupHabits(active, done); + done.Log(Today); SetupHabits(active, done); var result = await Execute("{}"); diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs index a5faa4a1..5c957741 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs @@ -170,6 +170,95 @@ public async Task Handle_HabitFound_MapsAllDetailFields() detail.ChecklistItems.Should().BeEmpty(); } + [Fact] + public async Task Handle_GeneralHabitLoggedToday_ReturnsCompleted() + { + var habit = Habit.Create(new HabitCreateParams( + UserId, + "General Habit", + null, + null, + DueDate: Today, + IsGeneral: true)).Value; + var log = habit.Log(Today).Value; + + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { habit }.AsReadOnly()); + _habitLogRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { log }.AsReadOnly()); + + var result = await _handler.Handle( + new GetHabitByIdQuery(UserId, habit.Id), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Handle_GeneralDescendantLoggedToday_ReturnsCompleted() + { + var parent = CreateTestHabit("Parent Habit"); + var child = Habit.Create(new HabitCreateParams( + UserId, + "General Child", + null, + null, + DueDate: Today, + IsGeneral: true, + ParentHabitId: parent.Id)).Value; + var log = child.Log(Today).Value; + AttachChild(parent, child); + + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { parent }.AsReadOnly()); + _habitLogRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(callInfo => + { + var predicate = callInfo.ArgAt>>(0).Compile(); + return new List { log }.Where(predicate).ToList().AsReadOnly(); + }); + + var result = await _handler.Handle( + new GetHabitByIdQuery(UserId, parent.Id), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Children.Should().ContainSingle(); + result.Value.Children[0].IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Handle_CompletedOneTimeHabitWithoutLogToday_ReturnsCompleted() + { + var dueDate = Today.AddDays(-1); + var habit = CreateOneTimeHabit("Completed Task", dueDate); + habit.Log(dueDate).IsSuccess.Should().BeTrue(); + + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { habit }.AsReadOnly()); + + var result = await _handler.Handle( + new GetHabitByIdQuery(UserId, habit.Id), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsCompleted.Should().BeTrue(); + } + [Fact] public async Task Handle_OverdueChild_ReturnsChildIsOverdue() { diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitFullDetailQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitFullDetailQueryHandlerTests.cs index f514cb68..3044d349 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitFullDetailQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitFullDetailQueryHandlerTests.cs @@ -5,6 +5,7 @@ using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; using System.Linq.Expressions; +using System.Reflection; namespace Orbit.Application.Tests.Queries.Habits; @@ -38,6 +39,14 @@ private static User CreateTestUser() return User.Create("Test User", "test@example.com").Value; } + private static void AttachChild(Habit parent, Habit child) + { + var field = typeof(Habit).GetField("_children", BindingFlags.Instance | BindingFlags.NonPublic); + var children = field?.GetValue(parent) as IList; + children.Should().NotBeNull(); + children!.Add(child); + } + [Fact] public async Task Handle_HabitAndUserFound_ReturnsFullDetail() { @@ -150,4 +159,108 @@ public async Task Handle_WithLogs_ReturnsLogsInResponse() result.IsSuccess.Should().BeTrue(); result.Value.Logs.Should().BeEmpty(); } + + [Fact] + public async Task Handle_GeneralHabitLoggedToday_ReturnsCompleted() + { + var habit = Habit.Create(new HabitCreateParams( + UserId, + "General Habit", + null, + null, + DueDate: Today, + IsGeneral: true)).Value; + var log = habit.Log(Today).Value; + var user = CreateTestUser(); + + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { habit }.AsReadOnly()); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _habitLogRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { log }.AsReadOnly()); + + var result = await _handler.Handle( + new GetHabitFullDetailQuery(UserId, habit.Id), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Habit.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Handle_GeneralDescendantLoggedToday_ReturnsCompleted() + { + var parent = CreateTestHabit(); + var child = Habit.Create(new HabitCreateParams( + UserId, + "General Child", + null, + null, + DueDate: Today, + IsGeneral: true, + ParentHabitId: parent.Id)).Value; + var log = child.Log(Today).Value; + var user = CreateTestUser(); + AttachChild(parent, child); + + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { parent }.AsReadOnly()); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _habitLogRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(callInfo => + { + var predicate = callInfo.ArgAt>>(0).Compile(); + return new List { log }.Where(predicate).ToList().AsReadOnly(); + }); + + var result = await _handler.Handle( + new GetHabitFullDetailQuery(UserId, parent.Id), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Habit.Children.Should().ContainSingle(); + result.Value.Habit.Children[0].IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Handle_CompletedOneTimeHabitWithoutLogToday_ReturnsCompleted() + { + var dueDate = Today.AddDays(-1); + var habit = Habit.Create(new HabitCreateParams( + UserId, + "Completed Task", + null, + null, + DueDate: dueDate)).Value; + var log = habit.Log(dueDate).Value; + var user = CreateTestUser(); + + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { habit }.AsReadOnly()); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _habitLogRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { log }.AsReadOnly()); + + var result = await _handler.Handle( + new GetHabitFullDetailQuery(UserId, habit.Id), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Habit.IsCompleted.Should().BeTrue(); + } } diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitScheduleQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitScheduleQueryHandlerTests.cs index e8323a79..52281ff5 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitScheduleQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitScheduleQueryHandlerTests.cs @@ -238,6 +238,190 @@ public async Task Handle_GeneralHabits_ReturnsGeneralOnly() result.Value.Items[0].IsGeneral.Should().BeTrue(); } + [Fact] + public async Task Handle_GeneralHabitLoggedToday_MapsDatedCompletion() + { + var general = CreateTestHabit( + title: "General", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + general.Log(Today).IsSuccess.Should().BeTrue(); + SetupHabits(general); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId, IsGeneral: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle().Which.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Handle_GeneralHabitLoggedOnlyYesterday_MapsIncompleteToday() + { + var general = CreateTestHabit( + title: "General", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + general.Log(Today.AddDays(-1)).IsSuccess.Should().BeTrue(); + SetupHabits(general); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId, IsGeneral: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle().Which.IsCompleted.Should().BeFalse(); + } + + [Fact] + public async Task Handle_GeneralHabitsHistoricalSelectedDay_MapsSelectedDayInsteadOfToday() + { + var selectedDay = Today.AddDays(-7); + var loggedOnSelectedDay = CreateTestHabit( + title: "Logged on selected day", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + var loggedToday = CreateTestHabit( + title: "Logged today", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + loggedOnSelectedDay.Log(selectedDay).IsSuccess.Should().BeTrue(); + loggedToday.Log(Today).IsSuccess.Should().BeTrue(); + SetupHabits(loggedOnSelectedDay, loggedToday); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId, selectedDay, selectedDay, IsGeneral: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle(h => h.Id == loggedOnSelectedDay.Id && h.IsCompleted); + result.Value.Items.Should().ContainSingle(h => h.Id == loggedToday.Id && !h.IsCompleted); + } + + [Fact] + public async Task Handle_GeneralHabitWithSoftDeletedLogToday_MapsIncomplete() + { + var general = CreateTestHabit( + title: "General", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + var log = general.Log(Today).Value; + log.SoftDelete(); + SetupHabits(general); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId, IsGeneral: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle().Which.IsCompleted.Should().BeFalse(); + } + + [Fact] + public async Task Handle_NestedGeneralChildLoggedToday_MapsDatedCompletion() + { + var parent = CreateTestHabit( + title: "Parent", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + var child = CreateTestHabit( + title: "Child", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true, + parentHabitId: parent.Id); + child.Log(Today).IsSuccess.Should().BeTrue(); + SetupHabits(parent, child); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId, IsGeneral: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + var item = result.Value.Items.Should().ContainSingle().Which; + item.IsCompleted.Should().BeFalse(); + item.Children.Should().ContainSingle().Which.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Handle_IncludeGeneral_MapsDatedCompletion() + { + var scheduled = CreateTestHabit(title: "Scheduled", dueDate: Today); + var general = CreateTestHabit( + title: "General", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + general.Log(Today).IsSuccess.Should().BeTrue(); + IReadOnlyList scheduledHabits = [scheduled]; + IReadOnlyList generalHabits = [general]; + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(scheduledHabits, scheduledHabits, generalHabits); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId, Today, Today, IncludeGeneral: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle(h => h.Id == general.Id && h.IsCompleted); + } + + [Fact] + public async Task Handle_IncludeGeneralHistoricalSelectedDay_MapsSelectedDayCompletion() + { + var selectedDay = Today.AddDays(-7); + var scheduled = CreateTestHabit(title: "Scheduled", dueDate: selectedDay); + var general = CreateTestHabit( + title: "General", + frequencyUnit: null, + frequencyQuantity: null, + isGeneral: true); + general.Log(selectedDay).IsSuccess.Should().BeTrue(); + IReadOnlyList scheduledHabits = [scheduled]; + IReadOnlyList generalHabits = [general]; + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(scheduledHabits, scheduledHabits, generalHabits); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId, selectedDay, selectedDay, IncludeGeneral: true), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle(h => h.Id == general.Id && h.IsCompleted); + } + + [Fact] + public async Task Handle_NonGeneralOneTimeTaskLoggedYesterday_RetainsLifetimeCompletion() + { + var task = CreateTestHabit( + title: "One time task", + frequencyUnit: null, + frequencyQuantity: null, + dueDate: Today.AddDays(-1)); + task.Log(Today.AddDays(-1)).IsSuccess.Should().BeTrue(); + SetupHabits(task); + + var result = await _handler.Handle( + new GetHabitScheduleQuery(UserId), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle().Which.IsCompleted.Should().BeTrue(); + } + [Fact] public async Task Handle_PageBeyondTotal_ClampsToLastPage() { diff --git a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs index 23b4cda3..57640f81 100644 --- a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs @@ -13,6 +13,8 @@ public class HabitTests private static readonly int[] ReminderTimes5And15And30 = [5, 15, 30]; private static readonly ChecklistItem[] SingleChecklistItem = [new("Item 1", false)]; private static readonly ChecklistItem[] SingleChecklistItemStep1 = [new("Step 1", false)]; + private static readonly DateOnly Yesterday = new(2026, 8, 5); + private static readonly DateOnly Today = new(2026, 8, 6); private static Habit CreateValidHabit( FrequencyUnit? frequencyUnit = FrequencyUnit.Day, @@ -45,6 +47,13 @@ private static Habit CreateOneTimeHabit(DateOnly? dueDate = null) DueDate: dueDate ?? DateOnly.FromDateTime(DateTime.UtcNow))).Value; } + private static Habit CreateGeneralHabit() + { + return Habit.Create(new HabitCreateParams( + ValidUserId, "Read a book someday", null, null, + DueDate: Today, IsGeneral: true)).Value; + } + [Fact] public void SoftDelete_MarksDeletedWithTimestamp() { @@ -239,6 +248,99 @@ public void Log_OneTime_MarksCompleted() habit.IsCompleted.Should().BeTrue(); } + [Fact] + public void Symptom3_GeneralLoggedYesterday_ShowsCompletedToday() + { + var habit = CreateGeneralHabit(); + + habit.Log(Yesterday).IsSuccess.Should().BeTrue(); + + habit.IsCompleted.Should().BeFalse(); + } + + [Fact] + public void Unlog_GeneralHabit_MissingDateFailsAndExistingDateDeletesOnlyMatchingLog() + { + var habit = CreateGeneralHabit(); + habit.Log(Yesterday).IsSuccess.Should().BeTrue(); + habit.Log(Today).IsSuccess.Should().BeTrue(); + + var missingDateResult = habit.Unlog(Today.AddDays(1)); + + missingDateResult.IsFailure.Should().BeTrue(); + habit.Logs.Should().OnlyContain(log => !log.IsDeleted); + + var existingDateResult = habit.Unlog(Today); + + existingDateResult.IsSuccess.Should().BeTrue(); + habit.Logs.Should().ContainSingle(log => log.Date == Yesterday && !log.IsDeleted); + habit.Logs.Should().ContainSingle(log => log.Date == Today && log.IsDeleted); + } + + [Fact] + public void Compounding_AfterFailedUnlog_TheHabitCannotBeLoggedAgainEither() + { + var habit = CreateGeneralHabit(); + habit.Log(Yesterday).IsSuccess.Should().BeTrue(); + habit.Unlog(Today); + + var result = habit.Log(Today); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Control_RecurringHabit_UnlogsFineOnTheDayItWasLogged() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, DueDate: Yesterday)).Value; + habit.Log(Yesterday).IsSuccess.Should().BeTrue(); + + var result = habit.Unlog(Yesterday); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Log_OneTimeTaskOnDifferentDate_MarksCompletedPermanently() + { + var habit = CreateOneTimeHabit(dueDate: Today); + + habit.Log(Yesterday).IsSuccess.Should().BeTrue(); + + habit.IsCompleted.Should().BeTrue(); + habit.Log(Today).IsFailure.Should().BeTrue(); + } + + [Fact] + public void GeneralHabit_LogAndUnlogTwiceOnSameDate_EndsUnlogged() + { + var habit = CreateGeneralHabit(); + + habit.Log(Today).IsSuccess.Should().BeTrue(); + habit.Unlog(Today).IsSuccess.Should().BeTrue(); + habit.Log(Today).IsSuccess.Should().BeTrue(); + habit.Unlog(Today).IsSuccess.Should().BeTrue(); + + habit.IsCompleted.Should().BeFalse(); + habit.Logs.Should().NotContain(log => log.Date == Today && !log.IsDeleted); + } + + [Fact] + public void Update_CompletedOneTimeTaskToGeneral_ClearsPermanentCompletion() + { + var habit = CreateOneTimeHabit(dueDate: Yesterday); + habit.Log(Yesterday).IsSuccess.Should().BeTrue(); + + var result = habit.Update(new HabitUpdateParams( + "Read a book someday", null, null, null, null, false, Today, IsGeneral: true)); + + result.IsSuccess.Should().BeTrue(); + habit.IsGeneral.Should().BeTrue(); + habit.IsCompleted.Should().BeFalse(); + habit.Log(Today).IsSuccess.Should().BeTrue(); + } + [Fact] public void Log_Recurring_AdvancesDueDate() {