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
8 changes: 6 additions & 2 deletions src/Orbit.Api/Controllers/HabitsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public record CreateHabitRequest(
TimeOnly? DueTime = null,
bool ReminderEnabled = false,
int ReminderMinutesBefore = 15,
bool SlipAlertEnabled = false,
IReadOnlyList<Guid>? TagIds = null);

public record UpdateHabitRequest(
Expand All @@ -37,7 +38,8 @@ public record UpdateHabitRequest(
DateOnly? DueDate = null,
TimeOnly? DueTime = null,
bool? ReminderEnabled = null,
int? ReminderMinutesBefore = null);
int? ReminderMinutesBefore = null,
bool? SlipAlertEnabled = null);

public record LogHabitRequest(string? Note = null);

Expand Down Expand Up @@ -136,6 +138,7 @@ public async Task<IActionResult> CreateHabit(
request.DueTime,
request.ReminderEnabled,
request.ReminderMinutesBefore,
request.SlipAlertEnabled,
request.TagIds);

var result = await mediator.Send(command, cancellationToken);
Expand Down Expand Up @@ -185,7 +188,8 @@ public async Task<IActionResult> UpdateHabit(
request.DueDate,
request.DueTime,
request.ReminderEnabled,
request.ReminderMinutesBefore);
request.ReminderMinutesBefore,
request.SlipAlertEnabled);

var result = await mediator.Send(command, cancellationToken);

Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
builder.Configuration.GetSection(VapidSettings.SectionName));
builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
builder.Services.AddHostedService<ReminderSchedulerService>();
builder.Services.AddHostedService<SlipAlertSchedulerService>();
builder.Services.AddHttpClient<ISlipAlertMessageService, GeminiSlipAlertMessageService>();

// Initialize Firebase Admin SDK for FCM
var firebaseCredJson = builder.Configuration["Firebase:CredentialsJson"];
Expand Down
8 changes: 6 additions & 2 deletions src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -373,16 +373,20 @@ public async Task<Result<ChatResponse>> Handle(

var dueDate = action.DueDate ?? await userDateService.GetUserTodayAsync(userId, ct);

var isBadHabit = action.IsBadHabit ?? false;
var slipAlertEnabled = action.SlipAlertEnabled ?? isBadHabit;

var habitResult = Habit.Create(
userId,
action.Title,
action.FrequencyUnit,
action.FrequencyQuantity,
action.Description,
days: action.Days,
isBadHabit: action.IsBadHabit ?? false,
isBadHabit: isBadHabit,
dueDate: dueDate,
dueTime: action.DueTime);
dueTime: action.DueTime,
slipAlertEnabled: slipAlertEnabled);

if (habitResult.IsFailure)
return Result.Failure<(Guid? Id, string? Name)>(habitResult.Error);
Expand Down
4 changes: 3 additions & 1 deletion src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public record CreateHabitCommand(
TimeOnly? DueTime = null,
bool ReminderEnabled = false,
int ReminderMinutesBefore = 15,
bool SlipAlertEnabled = false,
IReadOnlyList<Guid>? TagIds = null) : IRequest<Result<Guid>>;

public class CreateHabitCommandHandler(
Expand Down Expand Up @@ -59,7 +60,8 @@ public async Task<Result<Guid>> Handle(CreateHabitCommand request, CancellationT
dueDate,
dueTime: request.DueTime,
reminderEnabled: request.ReminderEnabled,
reminderMinutesBefore: request.ReminderMinutesBefore);
reminderMinutesBefore: request.ReminderMinutesBefore,
slipAlertEnabled: request.SlipAlertEnabled);

if (habitResult.IsFailure)
return Result.Failure<Guid>(habitResult.Error);
Expand Down
6 changes: 4 additions & 2 deletions src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public record UpdateHabitCommand(
DateOnly? DueDate = null,
TimeOnly? DueTime = null,
bool? ReminderEnabled = null,
int? ReminderMinutesBefore = null) : IRequest<Result>;
int? ReminderMinutesBefore = null,
bool? SlipAlertEnabled = null) : IRequest<Result>;

public class UpdateHabitCommandHandler(
IGenericRepository<Habit> habitRepository,
Expand Down Expand Up @@ -49,7 +50,8 @@ public async Task<Result> Handle(UpdateHabitCommand request, CancellationToken c
request.DueDate,
dueTime: request.DueTime,
reminderEnabled: request.ReminderEnabled,
reminderMinutesBefore: request.ReminderMinutesBefore);
reminderMinutesBefore: request.ReminderMinutesBefore,
slipAlertEnabled: request.SlipAlertEnabled);

if (result.IsFailure)
return result;
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public record HabitScheduleItem(
bool IsOverdue,
bool ReminderEnabled,
int ReminderMinutesBefore,
bool SlipAlertEnabled,
IReadOnlyList<HabitTagItem> Tags,
IReadOnlyList<HabitScheduleChildItem> Children);

Expand Down Expand Up @@ -178,6 +179,7 @@ private static HabitScheduleItem MapToScheduleItem(
isOverdue,
h.ReminderEnabled,
h.ReminderMinutesBefore,
h.SlipAlertEnabled,
MapTags(h),
MapChildren(h.Id, lookup, dateFrom, dateTo));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using Orbit.Domain.Entities;
using Orbit.Domain.Models;

namespace Orbit.Application.Habits.Services;

public static class SlipPatternDetectionService
{
private const int LookbackDays = 60;
private const int MinOccurrencesPerDay = 3;
private const int MinBucketCountForTimePeak = 2;

public static SlipPattern? DetectPattern(
IReadOnlyList<HabitLog> logs,
Guid habitId,
TimeZoneInfo userTimeZone)
{
var cutoff = DateTime.UtcNow.AddDays(-LookbackDays);
var recentLogs = logs.Where(l => l.CreatedAtUtc >= cutoff).ToList();

if (recentLogs.Count < MinOccurrencesPerDay)
return null;

// Convert to user local time and extract (DayOfWeek, Hour)
var localEntries = recentLogs.Select(l =>
{
var localTime = TimeZoneInfo.ConvertTimeFromUtc(l.CreatedAtUtc, userTimeZone);
return (localTime.DayOfWeek, localTime.Hour);
}).ToList();

// Group by DayOfWeek, filter to days with 3+ occurrences
var dayGroups = localEntries
.GroupBy(e => e.DayOfWeek)
.Where(g => g.Count() >= MinOccurrencesPerDay)
.ToList();

if (dayGroups.Count == 0)
return null;

SlipPattern? strongest = null;

foreach (var dayGroup in dayGroups)
{
// Bucket hours into 2-hour windows, pick peak window
var hourBuckets = dayGroup
.GroupBy(e => e.Hour / 2)
.OrderByDescending(g => g.Count())
.ToList();

var topBucket = hourBuckets.First();

// Only assign a peak hour if the top bucket has meaningful concentration
int? peakHour = topBucket.Count() >= MinBucketCountForTimePeak
? topBucket.Key * 2 + 1
: null;

var occurrenceCount = dayGroup.Count();
var confidence = (double)occurrenceCount / recentLogs.Count;

var pattern = new SlipPattern(
habitId,
dayGroup.Key,
peakHour,
occurrenceCount,
confidence);

if (strongest is null || confidence > strongest.Confidence)
strongest = pattern;
}

return strongest;
}
}
10 changes: 8 additions & 2 deletions src/Orbit.Domain/Entities/Habit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class Habit : Entity
public TimeOnly? DueTime { get; private set; }
public bool ReminderEnabled { get; private set; }
public int ReminderMinutesBefore { get; private set; } = 15;
public bool SlipAlertEnabled { get; private set; }
public int? Position { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
public ICollection<System.DayOfWeek> Days { get; private set; } = [];
Expand Down Expand Up @@ -46,7 +47,8 @@ public static Result<Habit> Create(
TimeOnly? dueTime = null,
Guid? parentHabitId = null,
bool reminderEnabled = false,
int reminderMinutesBefore = 15)
int reminderMinutesBefore = 15,
bool slipAlertEnabled = false)
{
if (userId == Guid.Empty)
return Result.Failure<Habit>("User ID is required.");
Expand Down Expand Up @@ -74,6 +76,7 @@ public static Result<Habit> Create(
ParentHabitId = parentHabitId,
ReminderEnabled = reminderEnabled,
ReminderMinutesBefore = reminderMinutesBefore,
SlipAlertEnabled = slipAlertEnabled,
CreatedAtUtc = DateTime.UtcNow
});
}
Expand Down Expand Up @@ -166,7 +169,8 @@ public Result Update(
DateOnly? dueDate,
TimeOnly? dueTime = null,
bool? reminderEnabled = null,
int? reminderMinutesBefore = null)
int? reminderMinutesBefore = null,
bool? slipAlertEnabled = null)
{
if (string.IsNullOrWhiteSpace(title))
return Result.Failure("Title is required.");
Expand All @@ -193,6 +197,8 @@ public Result Update(
ReminderEnabled = reminderEnabled.Value;
if (reminderMinutesBefore.HasValue)
ReminderMinutesBefore = reminderMinutesBefore.Value;
if (slipAlertEnabled.HasValue)
SlipAlertEnabled = slipAlertEnabled.Value;

return Result.Success();
}
Expand Down
22 changes: 22 additions & 0 deletions src/Orbit.Domain/Entities/SentSlipAlert.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Orbit.Domain.Common;

namespace Orbit.Domain.Entities;

public class SentSlipAlert : Entity
{
public Guid HabitId { get; private set; }
public DateOnly WeekStart { get; private set; }
public DateTime SentAtUtc { get; private set; }

private SentSlipAlert() { }

public static SentSlipAlert Create(Guid habitId, DateOnly weekStart)
{
return new SentSlipAlert
{
HabitId = habitId,
WeekStart = weekStart,
SentAtUtc = DateTime.UtcNow
};
}
}
13 changes: 13 additions & 0 deletions src/Orbit.Domain/Interfaces/ISlipAlertMessageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Orbit.Domain.Common;

namespace Orbit.Domain.Interfaces;

public interface ISlipAlertMessageService
{
Task<Result<(string Title, string Body)>> GenerateMessageAsync(
string habitTitle,
DayOfWeek dayOfWeek,
int? peakHour,
string language,
CancellationToken cancellationToken = default);
}
1 change: 1 addition & 0 deletions src/Orbit.Domain/Models/AiAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public record AiAction
public int? FrequencyQuantity { get; init; }
public List<System.DayOfWeek>? Days { get; init; }
public bool? IsBadHabit { get; init; }
public bool? SlipAlertEnabled { get; init; }
public DateOnly? DueDate { get; init; }
public TimeOnly? DueTime { get; init; }
public string? Note { get; init; }
Expand Down
8 changes: 8 additions & 0 deletions src/Orbit.Domain/Models/SlipPattern.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Orbit.Domain.Models;

public record SlipPattern(
Guid HabitId,
DayOfWeek DayOfWeek,
int? PeakHour,
int OccurrenceCount,
double Confidence);
Loading