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
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Orbit.Application.Habits.Services;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Chat.Tools.Implementations;

public class BulkSkipHabitsTool(
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IUserDateService userDateService) : IAiTool
{
public string Name => "bulk_skip_habits";
Expand Down Expand Up @@ -51,7 +54,8 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel
{
var habit = await habitRepository.FindOneTrackedAsync(
h => h.Id == habitId && h.UserId == userId,
cancellationToken: ct);
q => q.Include(h => h.Logs),
ct);

if (habit is null)
continue;
Expand All @@ -62,10 +66,25 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel
if (habit.FrequencyUnit is null)
continue;

if (habit.DueDate > today)
if (!habit.IsFlexible && habit.DueDate > today)
continue;

habit.AdvanceDueDate(today);
if (habit.IsFlexible)
{
var remaining = HabitScheduleService.GetRemainingCompletions(habit, today, habit.Logs);
if (remaining <= 0)
continue;

var skipResult = habit.SkipFlexible(today);
if (skipResult.IsFailure)
continue;

await habitLogRepository.AddAsync(skipResult.Value, ct);
}
else
{
habit.AdvanceDueDate(today);
}

skippedCount++;
skippedNames.Add(habit.Title);
Expand Down
19 changes: 17 additions & 2 deletions src/Orbit.Application/Chat/Tools/Implementations/SkipHabitTool.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Orbit.Application.Habits.Services;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
Expand All @@ -7,6 +8,7 @@ namespace Orbit.Application.Chat.Tools.Implementations;

public class SkipHabitTool(
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IUserDateService userDateService) : IAiTool
{
public string Name => "skip_habit";
Expand All @@ -33,7 +35,8 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel

var habit = await habitRepository.FindOneTrackedAsync(
h => h.Id == habitId && h.UserId == userId,
cancellationToken: ct);
q => q.Include(h => h.Logs),
ct);

if (habit is null)
return new ToolResult(false, Error: $"Habit {habitId} not found.");
Expand Down Expand Up @@ -65,9 +68,21 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel
return new ToolResult(false, Error: "Habit is not scheduled on this date.");

if (habit.IsFlexible)
habit.AdvanceDueDatePastWindow(today);
{
var remaining = HabitScheduleService.GetRemainingCompletions(habit, targetDate, habit.Logs);
if (remaining <= 0)
return new ToolResult(false, Error: "All instances for this period have already been completed or skipped.");

var skipResult = habit.SkipFlexible(targetDate);
if (skipResult.IsFailure)
return new ToolResult(false, Error: skipResult.Error);

await habitLogRepository.AddAsync(skipResult.Value, ct);
}
else
{
habit.AdvanceDueDate(targetDate);
}

return new ToolResult(true, EntityId: habit.Id.ToString(), EntityName: habit.Title);
}
Expand Down
33 changes: 31 additions & 2 deletions src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Application.Habits.Services;
Expand All @@ -24,6 +25,7 @@ public record BulkSkipItemResult(

public class BulkSkipHabitsCommandHandler(
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IUserDateService userDateService,
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<BulkSkipHabitsCommand, Result<BulkSkipResult>>
Expand Down Expand Up @@ -53,7 +55,8 @@ public async Task<Result<BulkSkipResult>> Handle(BulkSkipHabitsCommand request,

var habit = await habitRepository.FindOneTrackedAsync(
h => h.Id == habitId,
cancellationToken: cancellationToken);
q => q.Include(h => h.Logs),
cancellationToken);

if (habit is null)
{
Expand Down Expand Up @@ -116,9 +119,35 @@ public async Task<Result<BulkSkipResult>> Handle(BulkSkipHabitsCommand request,
}

if (habit.IsFlexible)
habit.AdvanceDueDatePastWindow(today);
{
var remaining = HabitScheduleService.GetRemainingCompletions(habit, targetDate, habit.Logs);
if (remaining <= 0)
{
results.Add(new BulkSkipItemResult(
Index: i,
Status: BulkItemStatus.Failed,
HabitId: habitId,
Error: "All instances for this period have already been completed or skipped."));
continue;
}

var skipResult = habit.SkipFlexible(targetDate);
if (skipResult.IsFailure)
{
results.Add(new BulkSkipItemResult(
Index: i,
Status: BulkItemStatus.Failed,
HabitId: habitId,
Error: skipResult.Error));
continue;
}

await habitLogRepository.AddAsync(skipResult.Value, cancellationToken);
}
else
{
habit.AdvanceDueDate(targetDate);
}

results.Add(new BulkSkipItemResult(
Index: i,
Expand Down
3 changes: 2 additions & 1 deletion src/Orbit.Application/Habits/Commands/LogHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ public async Task<Result<Guid>> Handle(LogHabitCommand request, CancellationToke
return Result.Failure<Guid>("Habit is not scheduled on this date.");

// Toggle: if already logged for the target date, unlog it (skip for flexible/bad habits which allow multiple logs)
var existingLog = habit.Logs.FirstOrDefault(l => l.Date == targetDate);
// Only match completion logs (Value > 0) to prevent toggle from removing skip logs (Value == 0)
var existingLog = habit.Logs.FirstOrDefault(l => l.Date == targetDate && l.Value > 0);
if (existingLog is not null && !habit.IsFlexible && !habit.IsBadHabit)
{
var unlogResult = habit.Unlog(targetDate);
Expand Down
21 changes: 18 additions & 3 deletions src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Common;
using Orbit.Application.Common.Attributes;
Expand Down Expand Up @@ -37,6 +38,7 @@ public record SkipHabitCommand(

public class SkipHabitCommandHandler(
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IUserDateService userDateService,
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<SkipHabitCommand, Result>
Expand All @@ -45,7 +47,8 @@ public async Task<Result> Handle(SkipHabitCommand request, CancellationToken can
{
var habit = await habitRepository.FindOneTrackedAsync(
h => h.Id == request.HabitId,
cancellationToken: cancellationToken);
q => q.Include(h => h.Logs),
cancellationToken);

if (habit is null)
return Result.Failure(ErrorMessages.HabitNotFound);
Expand All @@ -66,7 +69,7 @@ public async Task<Result> Handle(SkipHabitCommand request, CancellationToken can
if (targetDate > today)
return Result.Failure("Cannot skip a future date.");

// For flexible habits, skip means advance past current window
// For flexible habits, skip means record a skip log (Value=0) to reduce the period target
// For regular habits, they must be due on or before the target date
if (!habit.IsFlexible && habit.DueDate > targetDate)
return Result.Failure("Cannot skip a habit that is not yet due.");
Expand All @@ -76,9 +79,21 @@ public async Task<Result> Handle(SkipHabitCommand request, CancellationToken can
return Result.Failure("Habit is not scheduled on this date.");

if (habit.IsFlexible)
habit.AdvanceDueDatePastWindow(today);
{
var remaining = HabitScheduleService.GetRemainingCompletions(habit, targetDate, habit.Logs);
if (remaining <= 0)
return Result.Failure("All instances for this period have already been completed or skipped.");

var skipResult = habit.SkipFlexible(targetDate);
if (skipResult.IsFailure)
return Result.Failure(skipResult.Error);

await habitLogRepository.AddAsync(skipResult.Value, cancellationToken);
}
else
{
habit.AdvanceDueDate(targetDate);
}

await unitOfWork.SaveChangesAsync(cancellationToken);

Expand Down
10 changes: 7 additions & 3 deletions src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,9 @@ private static HabitScheduleItem MapToScheduleItem(
int? flexibleCompleted = null;
if (h.IsFlexible && referenceDate.HasValue)
{
flexibleTarget = h.FrequencyQuantity ?? 1;
var totalTarget = h.FrequencyQuantity ?? 1;
var skipped = HabitScheduleService.GetSkippedInWindow(h, referenceDate.Value, h.Logs);
flexibleTarget = Math.Max(0, totalTarget - skipped);
flexibleCompleted = HabitScheduleService.GetCompletedInWindow(h, referenceDate.Value, h.Logs);
}

Expand Down Expand Up @@ -409,11 +411,13 @@ private static List<HabitScheduleChildItem> MapChildren(
int? fc = null;
if (c.IsFlexible && referenceDate.HasValue)
{
ft = c.FrequencyQuantity ?? 1;
var childTotalTarget = c.FrequencyQuantity ?? 1;
var childSkipped = HabitScheduleService.GetSkippedInWindow(c, referenceDate.Value, c.Logs);
ft = Math.Max(0, childTotalTarget - childSkipped);
fc = HabitScheduleService.GetCompletedInWindow(c, referenceDate.Value, c.Logs);
}
var isLoggedInRange = dateFrom.HasValue && dateTo.HasValue
&& c.Logs.Any(l => l.Date >= dateFrom.Value && l.Date <= dateTo.Value);
&& c.Logs.Any(l => l.Date >= dateFrom.Value && l.Date <= dateTo.Value && l.Value > 0);

var instances = dateFrom.HasValue && dateTo.HasValue && userToday.HasValue
? HabitScheduleService.GetInstances(c, dateFrom.Value, dateTo.Value, userToday.Value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public static class HabitMetricsCalculator
{
public static HabitMetrics Calculate(Habit habit, DateOnly today)
{
var logDates = habit.Logs.Select(l => l.Date).Distinct().ToHashSet();
var logDates = habit.Logs.Where(l => l.Value > 0).Select(l => l.Date).Distinct().ToHashSet();
var expectedDates = GenerateExpectedDates(habit, today).ToList();

var currentStreak = CalculateCurrentStreak(habit, expectedDates, logDates, today);
Expand Down
20 changes: 17 additions & 3 deletions src/Orbit.Application/Habits/Services/HabitScheduleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,23 +123,37 @@ public static DateOnly GetWindowEnd(Habit habit, DateOnly target)
}

/// <summary>
/// Count of logs within the window containing target.
/// Count of completion logs (Value > 0) within the window containing target.
/// Skip logs (Value == 0) are excluded.
/// </summary>
public static int GetCompletedInWindow(Habit habit, DateOnly target, IReadOnlyCollection<HabitLog> logs)
{
var start = GetWindowStart(habit, target);
var end = GetWindowEnd(habit, target);
return logs.Count(l => l.Date >= start && l.Date <= end);
return logs.Count(l => l.Date >= start && l.Date <= end && l.Value > 0);
}

/// <summary>
/// Count of skip logs (Value == 0) within the window containing target.
/// </summary>
public static int GetSkippedInWindow(Habit habit, DateOnly target, IReadOnlyCollection<HabitLog> logs)
{
var start = GetWindowStart(habit, target);
var end = GetWindowEnd(habit, target);
return logs.Count(l => l.Date >= start && l.Date <= end && l.Value == 0);
}

/// <summary>
/// How many more completions are needed in the window containing target.
/// Skips reduce the target count for the period.
/// </summary>
public static int GetRemainingCompletions(Habit habit, DateOnly target, IReadOnlyCollection<HabitLog> logs)
{
var targetCount = habit.FrequencyQuantity ?? 1;
var skipped = GetSkippedInWindow(habit, target, logs);
var adjustedTarget = Math.Max(0, targetCount - skipped);
var completed = GetCompletedInWindow(habit, target, logs);
return Math.Max(0, targetCount - completed);
return Math.Max(0, adjustedTarget - completed);
}


Expand Down
17 changes: 16 additions & 1 deletion src/Orbit.Domain/Entities/Habit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,24 @@ public void AdvanceDueDatePastWindow(DateOnly today)
DueDate = windowEnd.AddDays(1);
}

public Result<HabitLog> SkipFlexible(DateOnly date)
{
if (!IsFlexible)
return Result.Failure<HabitLog>("Only flexible habits can be skipped this way.");

if (FrequencyUnit is null)
return Result.Failure<HabitLog>("Cannot skip a one-time task.");

// Create a skip log (Value = 0 distinguishes from completion logs which use Value = 1)
var log = HabitLog.Create(Id, date, 0, null);
_logs.Add(log);
return Result.Success(log);
}

public Result<HabitLog> Unlog(DateOnly date)
{
var log = _logs.Find(l => l.Date == date);
// Only match completion logs (Value > 0), not skip logs (Value == 0)
var log = _logs.Find(l => l.Date == date && l.Value > 0);
if (log is null)
return Result.Failure<HabitLog>("No log found for this date.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ private static string BuildRetrospectivePrompt(
{
var scheduledDates = HabitScheduleService.GetScheduledDates(habit, dateFrom, dateTo);
var scheduledCount = scheduledDates.Count;
var logs = habit.Logs.Where(l => l.Date >= dateFrom && l.Date <= dateTo).ToList();
var logs = habit.Logs.Where(l => l.Date >= dateFrom && l.Date <= dateTo && l.Value > 0).ToList();
var completedCount = logs.Count;

if (scheduledCount == 0 && completedCount == 0)
Expand All @@ -157,7 +157,7 @@ private static string BuildRetrospectivePrompt(
var children = habits.Where(h => h.ParentHabitId == habit.Id).ToList();
foreach (var child in children)
{
var childLogs = child.Logs.Where(l => l.Date >= dateFrom && l.Date <= dateTo).ToList();
var childLogs = child.Logs.Where(l => l.Date >= dateFrom && l.Date <= dateTo && l.Value > 0).ToList();
var childScheduled = HabitScheduleService.GetScheduledDates(child, dateFrom, dateTo).Count;
var childRate = childScheduled > 0 ? (int)Math.Round(100.0 * childLogs.Count / childScheduled) : 0;
habitLines.Add($" - {child.Title}: {childLogs.Count}/{childScheduled} ({childRate}%)");
Expand Down
Loading