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
6 changes: 5 additions & 1 deletion src/Orbit.Api/Mcp/Tools/HabitTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,11 @@ public async Task<string> GetRetrospective(
return $"Error: {result.Error}";

var r = result.Value;
return $"Retrospective ({period}){(r.FromCache ? " (cached)" : "")}:\n{r.Retrospective}";
var n = r.Narrative;
var narrativeText = string.Join(
"\n\n",
new[] { n.Highlights, n.Missed, n.Trends, n.Suggestion }.Where(s => !string.IsNullOrWhiteSpace(s)));
return $"Retrospective ({period}){(r.FromCache ? " (cached)" : "")}:\n{narrativeText}";
}

private static Guid GetUserId(ClaimsPrincipal user)
Expand Down
64 changes: 55 additions & 9 deletions src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Application.Habits.Services;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;

namespace Orbit.Application.Habits.Queries;

public record RetrospectiveResponse(string Retrospective, bool FromCache);
public record RetrospectiveHabitStat(
string Name,
string? Emoji,
int CompletionRate,
int CompletedCount,
int ScheduledCount);

public record RetrospectiveMetrics(
int CompletionRate,
int TotalCompletions,
int TotalScheduled,
int ActiveDays,
int PeriodDays,
int CurrentStreak,
int BestStreak,
int BadHabitSlips,
IReadOnlyList<int> WeeklyConsistency,
IReadOnlyList<RetrospectiveHabitStat> TopHabits,
IReadOnlyList<RetrospectiveHabitStat> NeedsAttention);

public record RetrospectiveResponse(
string Period,
RetrospectiveMetrics Metrics,
RetrospectiveNarrative Narrative,
bool FromCache);

public record GetRetrospectiveQuery(
Guid UserId,
Expand All @@ -21,6 +47,7 @@ public class GetRetrospectiveQueryHandler(
IGenericRepository<Habit> habitRepository,
IPayGateService payGate,
IRetrospectiveService retrospectiveService,
IUserStreakService userStreakService,
IMemoryCache cache) : IRequestHandler<GetRetrospectiveQuery, Result<RetrospectiveResponse>>
{
public async Task<Result<RetrospectiveResponse>> Handle(
Expand All @@ -31,10 +58,10 @@ public async Task<Result<RetrospectiveResponse>> Handle(
if (gateCheck.IsFailure)
return gateCheck.PropagateError<RetrospectiveResponse>();

var cacheKey = $"retro:{request.UserId}:{request.Period}:{request.DateFrom}:{request.Language}";
var cacheKey = $"retro:v2:{request.UserId}:{request.Period}:{request.DateFrom}:{request.Language}";

if (cache.TryGetValue(cacheKey, out string? cached) && cached is not null)
return Result.Success(new RetrospectiveResponse(cached, FromCache: true));
if (cache.TryGetValue(cacheKey, out RetrospectiveResponse? cached) && cached is not null)
return Result.Success(cached with { FromCache = true });

var habits = await habitRepository.FindAsync(
h => h.UserId == request.UserId,
Expand All @@ -46,22 +73,41 @@ public async Task<Result<RetrospectiveResponse>> Handle(
if (habitList.Count == 0)
return Result.Failure<RetrospectiveResponse>(ErrorMessages.NoHabitsForPeriod);

var result = await retrospectiveService.GenerateRetrospectiveAsync(
var streakState = await userStreakService.RecalculateAsync(
request.UserId, cancellationToken, awardFreezeIfEligible: false);

var metrics = RetrospectiveMetricsCalculator.Compute(
habitList,
request.DateFrom,
request.DateTo,
streakState?.CurrentStreak ?? 0,
streakState?.LongestStreak ?? 0);

if (metrics.TotalCompletions == 0 && metrics.BadHabitSlips == 0)
return Result.Failure<RetrospectiveResponse>(ErrorMessages.NoHabitsForPeriod);

var narrativeResult = await retrospectiveService.GenerateRetrospectiveAsync(
habitList,
request.DateFrom,
request.DateTo,
request.Period,
request.Language,
cancellationToken);

if (result.IsFailure)
return result.PropagateError<RetrospectiveResponse>();
if (narrativeResult.IsFailure)
return narrativeResult.PropagateError<RetrospectiveResponse>();

var response = new RetrospectiveResponse(
request.Period,
metrics,
narrativeResult.Value,
FromCache: false);

cache.Set(cacheKey, result.Value, new MemoryCacheEntryOptions
cache.Set(cacheKey, response, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});

return Result.Success(new RetrospectiveResponse(result.Value, FromCache: false));
return Result.Success(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using Orbit.Application.Habits.Queries;
using Orbit.Domain.Entities;

namespace Orbit.Application.Habits.Services;

/// <summary>
/// Computes the structured, deterministic metrics shown on the retrospective dashboard
/// (completion rates, streak echo, active/period days, per-weekday consistency, and the
/// top / needs-attention habit lists) from the habits and in-range logs already loaded by
/// the query handler. The AI narrative is produced separately by <see cref="IRetrospectiveService"/>.
/// </summary>
public static class RetrospectiveMetricsCalculator
{
private const int MaxHabitStats = 3;

private static readonly DayOfWeek[] WeekOrder =
[
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday,
DayOfWeek.Saturday,
DayOfWeek.Sunday
];

public static RetrospectiveMetrics Compute(
List<Habit> habits,
DateOnly dateFrom,
DateOnly dateTo,
int currentStreak,
int bestStreak)
{
var trackedHabits = habits.Where(h => h.ParentHabitId is null).ToList();

var totalCompletions = 0;
var totalScheduled = 0;
var badHabitSlips = 0;
var stats = new List<RetrospectiveHabitStat>();
var weekdayScheduled = new int[7];
var weekdayCompleted = new int[7];

foreach (var habit in trackedHabits)
{
var scheduledDates = HabitScheduleService.GetScheduledDates(habit, dateFrom, dateTo);
var completedCount = habit.Logs.Count(l => l.Date >= dateFrom && l.Date <= dateTo && l.Value > 0);

if (scheduledDates.Count == 0 && completedCount == 0)
continue;

if (habit.IsBadHabit)
{
badHabitSlips += completedCount;
continue;
}

totalScheduled += scheduledDates.Count;
totalCompletions += completedCount;

AccumulateWeekdayConsistency(habit, scheduledDates, weekdayScheduled, weekdayCompleted);
stats.Add(BuildHabitStat(habit, scheduledDates.Count, completedCount));
}

var completionRate = Percent(totalCompletions, totalScheduled);
var activeDays = CountActiveDays(habits, dateFrom, dateTo);
var periodDays = dateTo.DayNumber - dateFrom.DayNumber + 1;
var weeklyConsistency = BuildWeeklyConsistency(weekdayScheduled, weekdayCompleted);

var topHabits = stats
.OrderByDescending(s => s.CompletionRate)
.ThenByDescending(s => s.CompletedCount)
.Take(MaxHabitStats)
.ToList();

var needsAttention = stats
.Where(s => s.CompletionRate < 100)
.OrderBy(s => s.CompletionRate)
.ThenByDescending(s => s.ScheduledCount)
.Take(MaxHabitStats)
.ToList();

return new RetrospectiveMetrics(
completionRate,
totalCompletions,
totalScheduled,
activeDays,
periodDays,
currentStreak,
bestStreak,
badHabitSlips,
weeklyConsistency,
topHabits,
needsAttention);
}

private static void AccumulateWeekdayConsistency(
Habit habit, List<DateOnly> scheduledDates, int[] weekdayScheduled, int[] weekdayCompleted)
{
var completedDates = habit.Logs
.Where(l => l.Value > 0)
.Select(l => l.Date)
.ToHashSet();

foreach (var date in scheduledDates)
{
var index = WeekdayIndex(date.DayOfWeek);
weekdayScheduled[index]++;
if (completedDates.Contains(date))
weekdayCompleted[index]++;
}
}

private static RetrospectiveHabitStat BuildHabitStat(Habit habit, int scheduledCount, int completedCount) =>
new(
habit.Title,
habit.Emoji,
Percent(completedCount, scheduledCount),
completedCount,
scheduledCount);

private static IReadOnlyList<int> BuildWeeklyConsistency(int[] weekdayScheduled, int[] weekdayCompleted)

Check warning on line 121 in src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change return type of method 'BuildWeeklyConsistency' from 'System.Collections.Generic.IReadOnlyList<int>' to 'int[]' for improved performance

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ7IeqPPEhMlwP9QYSiL&open=AZ7IeqPPEhMlwP9QYSiL&pullRequest=207
{
var consistency = new int[7];
for (var i = 0; i < 7; i++)
consistency[i] = Percent(weekdayCompleted[i], weekdayScheduled[i]);
return consistency;
}

private static int CountActiveDays(List<Habit> habits, DateOnly dateFrom, DateOnly dateTo)
{
var activeDates = new HashSet<DateOnly>();
foreach (var habit in habits)
{
foreach (var log in habit.Logs)
{
if (log.Value > 0 && log.Date >= dateFrom && log.Date <= dateTo)
activeDates.Add(log.Date);
}
}
return activeDates.Count;
}

private static int WeekdayIndex(DayOfWeek day) => Array.IndexOf(WeekOrder, day);

private static int Percent(int numerator, int denominator) =>
denominator > 0 ? (int)Math.Round(100.0 * numerator / denominator) : 0;
}
3 changes: 2 additions & 1 deletion src/Orbit.Domain/Interfaces/IRetrospectiveService.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Models;

namespace Orbit.Domain.Interfaces;

public interface IRetrospectiveService
{
Task<Result<string>> GenerateRetrospectiveAsync(
Task<Result<RetrospectiveNarrative>> GenerateRetrospectiveAsync(
List<Habit> habits,
DateOnly dateFrom,
DateOnly dateTo,
Expand Down
12 changes: 12 additions & 0 deletions src/Orbit.Domain/Models/RetrospectiveNarrative.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Orbit.Domain.Models;

/// <summary>
/// The four plain-text sections of an AI-generated retrospective. The AI service emits a single
/// labeled document which is parsed into these fields; on a parse miss the whole text lands in
/// <see cref="Highlights"/> and the rest are empty.
/// </summary>
public record RetrospectiveNarrative(
string Highlights,
string Missed,
string Trends,
string Suggestion);
Loading
Loading