diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkSkipHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkSkipHabitsTool.cs index f521048b..806d2dd6 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/BulkSkipHabitsTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkSkipHabitsTool.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Habits.Services; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -6,6 +8,7 @@ namespace Orbit.Application.Chat.Tools.Implementations; public class BulkSkipHabitsTool( IGenericRepository habitRepository, + IGenericRepository habitLogRepository, IUserDateService userDateService) : IAiTool { public string Name => "bulk_skip_habits"; @@ -51,7 +54,8 @@ public async Task 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; @@ -62,10 +66,25 @@ public async Task 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); diff --git a/src/Orbit.Application/Chat/Tools/Implementations/SkipHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/SkipHabitTool.cs index 8d54e8e3..57951b38 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/SkipHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/SkipHabitTool.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Orbit.Application.Habits.Services; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -7,6 +8,7 @@ namespace Orbit.Application.Chat.Tools.Implementations; public class SkipHabitTool( IGenericRepository habitRepository, + IGenericRepository habitLogRepository, IUserDateService userDateService) : IAiTool { public string Name => "skip_habit"; @@ -33,7 +35,8 @@ public async Task 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."); @@ -65,9 +68,21 @@ public async Task 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); } diff --git a/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs index fd5a0c19..b2a00b88 100644 --- a/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Application.Habits.Services; @@ -24,6 +25,7 @@ public record BulkSkipItemResult( public class BulkSkipHabitsCommandHandler( IGenericRepository habitRepository, + IGenericRepository habitLogRepository, IUserDateService userDateService, IUnitOfWork unitOfWork, IMemoryCache cache) : IRequestHandler> @@ -53,7 +55,8 @@ public async Task> 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) { @@ -116,9 +119,35 @@ public async Task> 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, diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index 9b26ca22..21ad6428 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -70,7 +70,8 @@ public async Task> Handle(LogHabitCommand request, CancellationToke return Result.Failure("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); diff --git a/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs index e5f03899..b3698054 100644 --- a/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Application.Common.Attributes; @@ -37,6 +38,7 @@ public record SkipHabitCommand( public class SkipHabitCommandHandler( IGenericRepository habitRepository, + IGenericRepository habitLogRepository, IUserDateService userDateService, IUnitOfWork unitOfWork, IMemoryCache cache) : IRequestHandler @@ -45,7 +47,8 @@ public async Task 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); @@ -66,7 +69,7 @@ public async Task 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."); @@ -76,9 +79,21 @@ public async Task 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); diff --git a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs index 3e49f4c2..4c716699 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs @@ -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); } @@ -409,11 +411,13 @@ private static List 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) diff --git a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs index ee9bb9ae..88ab2267 100644 --- a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs @@ -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); diff --git a/src/Orbit.Application/Habits/Services/HabitScheduleService.cs b/src/Orbit.Application/Habits/Services/HabitScheduleService.cs index 111e2444..1b0f6af0 100644 --- a/src/Orbit.Application/Habits/Services/HabitScheduleService.cs +++ b/src/Orbit.Application/Habits/Services/HabitScheduleService.cs @@ -123,23 +123,37 @@ public static DateOnly GetWindowEnd(Habit habit, DateOnly target) } /// - /// 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. /// public static int GetCompletedInWindow(Habit habit, DateOnly target, IReadOnlyCollection 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); + } + + /// + /// Count of skip logs (Value == 0) within the window containing target. + /// + public static int GetSkippedInWindow(Habit habit, DateOnly target, IReadOnlyCollection logs) + { + var start = GetWindowStart(habit, target); + var end = GetWindowEnd(habit, target); + return logs.Count(l => l.Date >= start && l.Date <= end && l.Value == 0); } /// /// How many more completions are needed in the window containing target. + /// Skips reduce the target count for the period. /// public static int GetRemainingCompletions(Habit habit, DateOnly target, IReadOnlyCollection 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); } diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index edaa4a4b..c1da468d 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -235,9 +235,24 @@ public void AdvanceDueDatePastWindow(DateOnly today) DueDate = windowEnd.AddDays(1); } + public Result SkipFlexible(DateOnly date) + { + if (!IsFlexible) + return Result.Failure("Only flexible habits can be skipped this way."); + + if (FrequencyUnit is null) + return Result.Failure("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 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("No log found for this date."); diff --git a/src/Orbit.Infrastructure/Services/GeminiRetrospectiveService.cs b/src/Orbit.Infrastructure/Services/GeminiRetrospectiveService.cs index d0d786d8..4da7ec8e 100644 --- a/src/Orbit.Infrastructure/Services/GeminiRetrospectiveService.cs +++ b/src/Orbit.Infrastructure/Services/GeminiRetrospectiveService.cs @@ -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) @@ -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}%)");