From f4f37e3e0cf915903577d4040359686c7265c7d4 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 3 Oct 2026 03:42:42 -0300 Subject: [PATCH 1/2] fix: include habit ids in retrospective rankings --- src/Orbit.Api/openapi.json | 7 +++ .../Habits/Queries/GetRetrospectiveQuery.cs | 3 +- .../RetrospectiveMetricsCalculator.cs | 3 +- .../GetRetrospectiveQueryHandlerTests.cs | 44 +++++++++++++++++++ .../Services/RetrospectiveHabitStatTests.cs | 43 ++++++++++++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/Orbit.Application.Tests/Services/RetrospectiveHabitStatTests.cs diff --git a/src/Orbit.Api/openapi.json b/src/Orbit.Api/openapi.json index 0e008eed0..5579f47f8 100644 --- a/src/Orbit.Api/openapi.json +++ b/src/Orbit.Api/openapi.json @@ -17360,6 +17360,13 @@ "isOneTime": { "type": "boolean", "default": false + }, + "habitId": { + "type": [ + "null", + "string" + ], + "format": "uuid" } } }, diff --git a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs index d18ae92ed..ba34621cd 100644 --- a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs @@ -16,7 +16,8 @@ public record RetrospectiveHabitStat( int CompletionRate, int CompletedCount, int ScheduledCount, - bool IsOneTime = false); + bool IsOneTime = false, + Guid? HabitId = null); /// Structured habit metrics for a retrospective or recap. /// Seven weekday percentages ordered Monday through Sunday. diff --git a/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs index 15ea13032..5a60710c4 100644 --- a/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs @@ -264,7 +264,8 @@ private static RetrospectiveHabitStat BuildHabitStat( Percent(metCount, scheduledCount), completedCount, scheduledCount, - habit.FrequencyUnit is null); + habit.FrequencyUnit is null, + habit.Id); private static int[] BuildWeeklyConsistency(int[] weekdayScheduled, int[] weekdayCompleted) { diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs index 1683f9ca7..3835876f4 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs @@ -8,7 +8,10 @@ using Orbit.Domain.Interfaces; using Orbit.Domain.Models; using System.Linq.Expressions; +using System.Text.Json; using Orbit.Application.Common; +using Orbit.Application.Chat; +using Orbit.Application.Habits.Services; namespace Orbit.Application.Tests.Queries.Habits; @@ -173,6 +176,12 @@ public async Task Handle_TopHabits_OrderedByHighestRateFirst() top[0].CompletionRate.Should().Be(100); top[0].Emoji.Should().BeNull(); top[1].Name.Should().Be("Weak"); + + var response = JsonSerializer.SerializeToElement(result.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var serializedTop = response.GetProperty("metrics").GetProperty("topHabits"); + serializedTop[0].TryGetProperty("habitId", out var strongId).Should().BeTrue(); + strongId.GetGuid().Should().Be(strong.Id); + serializedTop[1].GetProperty("habitId").GetGuid().Should().Be(weak.Id); } [Fact] @@ -196,6 +205,12 @@ public async Task Handle_NeedsAttention_OrderedByLowestRate_ExcludesPerfectHabit needs.Should().NotContain(s => s.Name == "Perfect"); needs[0].Name.Should().Be("Weak"); needs[1].Name.Should().Be("Middling"); + + var response = JsonSerializer.SerializeToElement(result.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var serializedNeeds = response.GetProperty("metrics").GetProperty("needsAttention"); + serializedNeeds[0].TryGetProperty("habitId", out var weakId).Should().BeTrue(); + weakId.GetGuid().Should().Be(weak.Id); + serializedNeeds[1].GetProperty("habitId").GetGuid().Should().Be(middling.Id); } [Fact] @@ -242,6 +257,35 @@ public async Task Handle_ReturnsCachedResult_WhenCached() result.Value.Narrative.Should().Be(SampleNarrative); } + [Fact] + public async Task Handle_CachedStatWithoutHabitId_RemainsReadableByInsightCard() + { + var legacyStat = JsonSerializer.Deserialize( + """{"name":"Read","emoji":null,"completionRate":14,"completedCount":1,"scheduledCount":7,"isOneTime":false}""", + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + legacyStat.Should().NotBeNull(); + legacyStat!.HabitId.Should().BeNull(); + new RetrospectiveHabitStat("Read", null, 14, 1, 7).HabitId.Should().BeNull(); + var metrics = RetrospectiveMetricsCalculator.Compute([CreateLoggedHabit()], DateFrom, DateTo, 4, 9) + with + { TopHabits = [legacyStat], NeedsAttention = [legacyStat] }; + _cache.Set(RetrospectiveCacheKey.Build(UserId, "week", DateFrom, "en"), + new RetrospectiveResponse("week", metrics, SampleNarrative, false, DateFrom, DateTo)); + + var result = await HandleWeek(); + + result.IsSuccess.Should().BeTrue(); + result.Value.FromCache.Should().BeTrue(); + var card = PeriodInsightCardBuilder.Build(result.Value); + card.Should().NotBeNull(); + card!.TopHabits.Should().ContainSingle().Which.HabitId.Should().BeNull(); + card.NeedsAttention.Should().ContainSingle().Which.HabitId.Should().BeNull(); + await _habitRepo.DidNotReceive().FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()); + } + [Fact] public async Task Handle_CacheHit_DoesNotRegenerateNarrative() { diff --git a/tests/Orbit.Application.Tests/Services/RetrospectiveHabitStatTests.cs b/tests/Orbit.Application.Tests/Services/RetrospectiveHabitStatTests.cs new file mode 100644 index 000000000..796bca8ee --- /dev/null +++ b/tests/Orbit.Application.Tests/Services/RetrospectiveHabitStatTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using Orbit.Application.Chat; +using Orbit.Application.Habits.Queries; +using Orbit.Application.Habits.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Models; + +namespace Orbit.Application.Tests.Services; + +public class RetrospectiveHabitStatTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Compute_HabitRankingsAndInsightCard_PreserveIdsForDuplicateTitles(bool historical) + { + var from = new DateOnly(2026, 3, 2); + var to = from.AddDays(6); + var userId = Guid.NewGuid(); + var strong = Habit.Create(new HabitCreateParams(userId, "Read", FrequencyUnit.Day, 1, from)).Value; + var weak = Habit.Create(new HabitCreateParams(userId, "Read", FrequencyUnit.Day, 1, from)).Value; + foreach (var habit in new[] { strong, weak }) + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))!.SetValue( + habit, from.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + for (var i = 0; i < 7; i++) + strong.Log(from.AddDays(i), advanceDueDate: false).IsSuccess.Should().BeTrue(); + weak.Log(from, advanceDueDate: false).IsSuccess.Should().BeTrue(); + + var metrics = historical + ? RetrospectiveMetricsCalculator.ComputeHistorical([strong, weak], from, to, 0, 0, TimeZoneInfo.Utc) + : RetrospectiveMetricsCalculator.Compute([strong, weak], from, to, 0, 0); + + metrics.TopHabits.Select(stat => stat.HabitId).Should().Equal(strong.Id, weak.Id); + metrics.NeedsAttention.Should().ContainSingle().Which.HabitId.Should().Be(weak.Id); + var card = PeriodInsightCardBuilder.Build(new RetrospectiveResponse( + "week", metrics, new RetrospectiveNarrative("Highlights", "Missed", "Trends", "Suggestion"), + false, from, to)); + card.Should().NotBeNull(); + card!.TopHabits.Select(stat => stat.HabitId).Should().Equal(strong.Id, weak.Id); + card.NeedsAttention.Should().ContainSingle().Which.HabitId.Should().Be(weak.Id); + } +} From a74c1aa52e2cc0fc151dd0c1d30fb5a38e4f2fd9 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 3 Oct 2026 03:48:33 -0300 Subject: [PATCH 2/2] test: fix achievement count log creation time --- .../Gamification/AchievementProgressServiceTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs b/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs index f332ee39c..8058ddc55 100644 --- a/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs +++ b/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs @@ -115,6 +115,8 @@ public async Task LoadAsync_WithHabits_MapsEveryCountToItsMetric() var user = CreateUser(streak: 9); var habit = CreateHabitWithStreak(5); var oldLog = habit.Log(Today.AddDays(-65), advanceDueDate: false).Value; + typeof(HabitLog).GetProperty(nameof(HabitLog.CreatedAtUtc))! + .SetValue(oldLog, oldLog.Date.ToDateTime(new TimeOnly(12, 0), DateTimeKind.Utc)); StubHabits(habit); StubCounts();