diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index 89305914..92417632 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -25,6 +25,7 @@ public record CreateHabitRequest( TimeOnly? DueTime = null, bool ReminderEnabled = false, int ReminderMinutesBefore = 15, + bool SlipAlertEnabled = false, IReadOnlyList? TagIds = null); public record UpdateHabitRequest( @@ -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); @@ -136,6 +138,7 @@ public async Task CreateHabit( request.DueTime, request.ReminderEnabled, request.ReminderMinutesBefore, + request.SlipAlertEnabled, request.TagIds); var result = await mediator.Send(command, cancellationToken); @@ -185,7 +188,8 @@ public async Task UpdateHabit( request.DueDate, request.DueTime, request.ReminderEnabled, - request.ReminderMinutesBefore); + request.ReminderMinutesBefore, + request.SlipAlertEnabled); var result = await mediator.Send(command, cancellationToken); diff --git a/src/Orbit.Api/Program.cs b/src/Orbit.Api/Program.cs index 105db52d..1b655610 100644 --- a/src/Orbit.Api/Program.cs +++ b/src/Orbit.Api/Program.cs @@ -63,6 +63,8 @@ builder.Configuration.GetSection(VapidSettings.SectionName)); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHttpClient(); // Initialize Firebase Admin SDK for FCM var firebaseCredJson = builder.Configuration["Firebase:CredentialsJson"]; diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 06de901f..3543d7be 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -373,6 +373,9 @@ public async Task> 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, @@ -380,9 +383,10 @@ public async Task> Handle( 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); diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index 2700729b..5a6d20b6 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -21,6 +21,7 @@ public record CreateHabitCommand( TimeOnly? DueTime = null, bool ReminderEnabled = false, int ReminderMinutesBefore = 15, + bool SlipAlertEnabled = false, IReadOnlyList? TagIds = null) : IRequest>; public class CreateHabitCommandHandler( @@ -59,7 +60,8 @@ public async Task> 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(habitResult.Error); diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index 59c9fd50..68d7128e 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -21,7 +21,8 @@ public record UpdateHabitCommand( DateOnly? DueDate = null, TimeOnly? DueTime = null, bool? ReminderEnabled = null, - int? ReminderMinutesBefore = null) : IRequest; + int? ReminderMinutesBefore = null, + bool? SlipAlertEnabled = null) : IRequest; public class UpdateHabitCommandHandler( IGenericRepository habitRepository, @@ -49,7 +50,8 @@ public async Task 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; diff --git a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs index a4987a7c..57b09880 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs @@ -27,6 +27,7 @@ public record HabitScheduleItem( bool IsOverdue, bool ReminderEnabled, int ReminderMinutesBefore, + bool SlipAlertEnabled, IReadOnlyList Tags, IReadOnlyList Children); @@ -178,6 +179,7 @@ private static HabitScheduleItem MapToScheduleItem( isOverdue, h.ReminderEnabled, h.ReminderMinutesBefore, + h.SlipAlertEnabled, MapTags(h), MapChildren(h.Id, lookup, dateFrom, dateTo)); diff --git a/src/Orbit.Application/Habits/Services/SlipPatternDetectionService.cs b/src/Orbit.Application/Habits/Services/SlipPatternDetectionService.cs new file mode 100644 index 00000000..9df36f9e --- /dev/null +++ b/src/Orbit.Application/Habits/Services/SlipPatternDetectionService.cs @@ -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 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; + } +} diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index 88f97d3a..a6a7ae27 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -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 Days { get; private set; } = []; @@ -46,7 +47,8 @@ public static Result 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("User ID is required."); @@ -74,6 +76,7 @@ public static Result Create( ParentHabitId = parentHabitId, ReminderEnabled = reminderEnabled, ReminderMinutesBefore = reminderMinutesBefore, + SlipAlertEnabled = slipAlertEnabled, CreatedAtUtc = DateTime.UtcNow }); } @@ -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."); @@ -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(); } diff --git a/src/Orbit.Domain/Entities/SentSlipAlert.cs b/src/Orbit.Domain/Entities/SentSlipAlert.cs new file mode 100644 index 00000000..bee337be --- /dev/null +++ b/src/Orbit.Domain/Entities/SentSlipAlert.cs @@ -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 + }; + } +} diff --git a/src/Orbit.Domain/Interfaces/ISlipAlertMessageService.cs b/src/Orbit.Domain/Interfaces/ISlipAlertMessageService.cs new file mode 100644 index 00000000..fedb22df --- /dev/null +++ b/src/Orbit.Domain/Interfaces/ISlipAlertMessageService.cs @@ -0,0 +1,13 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Interfaces; + +public interface ISlipAlertMessageService +{ + Task> GenerateMessageAsync( + string habitTitle, + DayOfWeek dayOfWeek, + int? peakHour, + string language, + CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Domain/Models/AiAction.cs b/src/Orbit.Domain/Models/AiAction.cs index d8b58420..bab240e0 100644 --- a/src/Orbit.Domain/Models/AiAction.cs +++ b/src/Orbit.Domain/Models/AiAction.cs @@ -12,6 +12,7 @@ public record AiAction public int? FrequencyQuantity { get; init; } public List? 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; } diff --git a/src/Orbit.Domain/Models/SlipPattern.cs b/src/Orbit.Domain/Models/SlipPattern.cs new file mode 100644 index 00000000..59d855d8 --- /dev/null +++ b/src/Orbit.Domain/Models/SlipPattern.cs @@ -0,0 +1,8 @@ +namespace Orbit.Domain.Models; + +public record SlipPattern( + Guid HabitId, + DayOfWeek DayOfWeek, + int? PeakHour, + int OccurrenceCount, + double Confidence); diff --git a/src/Orbit.Infrastructure/Migrations/20260320222357_AddSlipAlertEnabled.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260320222357_AddSlipAlertEnabled.Designer.cs new file mode 100644 index 00000000..17feb323 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260320222357_AddSlipAlertEnabled.Designer.cs @@ -0,0 +1,469 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260320222357_AddSlipAlertEnabled")] + partial class AddSlipAlertEnabled + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderMinutesBefore") + .HasColumnType("integer"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date"); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date") + .IsUnique(); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("CompletedTours") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasDismissedMissions") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260320222357_AddSlipAlertEnabled.cs b/src/Orbit.Infrastructure/Migrations/20260320222357_AddSlipAlertEnabled.cs new file mode 100644 index 00000000..a69d2f88 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260320222357_AddSlipAlertEnabled.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddSlipAlertEnabled : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SlipAlertEnabled", + table: "Habits", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "SentSlipAlerts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + HabitId = table.Column(type: "uuid", nullable: false), + WeekStart = table.Column(type: "date", nullable: false), + SentAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SentSlipAlerts", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_SentSlipAlerts_HabitId_WeekStart", + table: "SentSlipAlerts", + columns: new[] { "HabitId", "WeekStart" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SentSlipAlerts"); + + migrationBuilder.DropColumn( + name: "SlipAlertEnabled", + table: "Habits"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index f66d257d..c789e10e 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -126,6 +126,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReminderMinutesBefore") .HasColumnType("integer"); + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + b.Property("Title") .IsRequired() .HasColumnType("text"); @@ -263,6 +266,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SentReminders"); }); + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => { b.Property("Id") diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index ce747f78..3bd09fcd 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -14,6 +14,7 @@ public class OrbitDbContext(DbContextOptions options) : DbContex public DbSet Tags => Set(); public DbSet PushSubscriptions => Set(); public DbSet SentReminders => Set(); + public DbSet SentSlipAlerts => Set(); public DbSet Notifications => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -82,6 +83,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(r => new { r.HabitId, r.Date }).IsUnique(); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(a => new { a.HabitId, a.WeekStart }).IsUnique(); + }); + modelBuilder.Entity(entity => { entity.HasIndex(n => new { n.UserId, n.IsRead }); diff --git a/src/Orbit.Infrastructure/Services/GeminiSlipAlertMessageService.cs b/src/Orbit.Infrastructure/Services/GeminiSlipAlertMessageService.cs new file mode 100644 index 00000000..b5d02c24 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/GeminiSlipAlertMessageService.cs @@ -0,0 +1,154 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orbit.Domain.Common; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; + +namespace Orbit.Infrastructure.Services; + +public sealed class GeminiSlipAlertMessageService( + HttpClient httpClient, + IOptions options, + ILogger logger) : ISlipAlertMessageService +{ + private readonly GeminiSettings _settings = options.Value; + + public async Task> GenerateMessageAsync( + string habitTitle, + DayOfWeek dayOfWeek, + int? peakHour, + string language, + CancellationToken cancellationToken = default) + { + var languageName = language.ToLowerInvariant() switch + { + "pt-br" or "pt" => "Brazilian Portuguese", + _ => "English" + }; + + var timeContext = peakHour.HasValue + ? $"They tend to slip around {peakHour.Value}:00 on {dayOfWeek}s." + : $"They tend to slip on {dayOfWeek}s (no specific time pattern)."; + + var prompt = $""" + You are a supportive habit coach sending a push notification to help someone avoid a bad habit slip-up. + + Bad habit: "{habitTitle}" + Pattern: {timeContext} + + Generate a short, inspiring push notification to help them stay strong today. + + Rules: + - Return EXACTLY two lines: first line is the notification title, second line is the body + - Title: 5-8 words max, personal and warm (e.g., "Stay strong today!" or "You've got this!") + - Body: 1-2 sentences max, motivational and specific to their habit + - Be creative and varied -- don't use the same structure every time + - Tone: supportive friend, not preachy or judgmental + - Do NOT use emojis + - Do NOT mention the app name + - Write ONLY in {languageName} + - No quotes or formatting, just plain text + """; + + var request = new GeminiRequest + { + Contents = + [ + new GeminiContent + { + Parts = [new GeminiPart { Text = prompt }] + } + ], + GenerationConfig = new GeminiGenerationConfig + { + Temperature = 0.9 + } + }; + + try + { + var response = await httpClient.PostAsJsonAsync( + $"{_settings.BaseUrl}/models/{_settings.Model}:generateContent?key={_settings.ApiKey}", + request, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("Gemini API returned {Status} for slip alert message", response.StatusCode); + return GenerateFallback(habitTitle, language); + } + + var geminiResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + var text = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text; + + if (string.IsNullOrWhiteSpace(text)) + { + logger.LogWarning("Gemini returned empty response for slip alert message"); + return GenerateFallback(habitTitle, language); + } + + var lines = text.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (lines.Length >= 2) + return Result.Success((lines[0], lines[1])); + + // If only one line, use it as body with a generic title + var fallbackTitle = language.StartsWith("pt") ? $"Fique atento: {habitTitle}" : $"Heads up: {habitTitle}"; + return Result.Success((fallbackTitle, lines[0])); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to generate slip alert message via AI"); + return GenerateFallback(habitTitle, language); + } + } + + private static Result<(string Title, string Body)> GenerateFallback(string habitTitle, string language) + { + return language.StartsWith("pt") + ? Result.Success(($"Fique atento: {habitTitle}", + "Voce costuma deslizar por volta desse horario. Forca -- voce consegue!")) + : Result.Success(($"Heads up: {habitTitle}", + "You tend to slip around this time. Stay strong -- you've got this!")); + } + + private record GeminiRequest + { + [JsonPropertyName("contents")] + public GeminiContent[] Contents { get; init; } = []; + + [JsonPropertyName("generationConfig")] + public GeminiGenerationConfig? GenerationConfig { get; init; } + } + + private record GeminiContent + { + [JsonPropertyName("parts")] + public GeminiPart[] Parts { get; init; } = []; + } + + private record GeminiPart + { + [JsonPropertyName("text")] + public string Text { get; init; } = string.Empty; + } + + private record GeminiGenerationConfig + { + [JsonPropertyName("temperature")] + public double Temperature { get; init; } + } + + private record GeminiResponse + { + [JsonPropertyName("candidates")] + public GeminiCandidate[]? Candidates { get; init; } + } + + private record GeminiCandidate + { + [JsonPropertyName("content")] + public GeminiContent? Content { get; init; } + } +} diff --git a/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs b/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs new file mode 100644 index 00000000..eb6d3e60 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orbit.Application.Habits.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Services; + +public class SlipAlertSchedulerService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private const int DefaultMorningHour = 8; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("SlipAlertSchedulerService started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CheckAndSendAlerts(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Error in slip alert scheduler"); + } + + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + + private async Task CheckAndSendAlerts(CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var pushService = scope.ServiceProvider.GetRequiredService(); + var messageService = scope.ServiceProvider.GetRequiredService(); + + // Load active bad habits with slip alerts enabled + var habits = await dbContext.Habits + .Where(h => h.IsActive && !h.IsCompleted && h.IsBadHabit && h.SlipAlertEnabled) + .ToListAsync(ct); + + if (habits.Count == 0) return; + + // Group by user to handle timezones + var userIds = habits.Select(h => h.UserId).Distinct().ToList(); + var users = await dbContext.Users + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, ct); + + foreach (var habit in habits) + { + if (!users.TryGetValue(habit.UserId, out var user)) continue; + + var tz = user.TimeZone is not null + ? TimeZoneInfo.FindSystemTimeZoneById(user.TimeZone) + : TimeZoneInfo.Utc; + var userNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); + var userToday = DateOnly.FromDateTime(userNow); + var userTimeNow = TimeOnly.FromDateTime(userNow); + + // Load habit logs for pattern detection + var logs = await dbContext.HabitLogs + .Where(l => l.HabitId == habit.Id) + .ToListAsync(ct); + + var pattern = SlipPatternDetectionService.DetectPattern(logs, habit.Id, tz); + if (pattern is null) continue; + + // Check if today matches the pattern's day of week + if (userNow.DayOfWeek != pattern.DayOfWeek) continue; + + // Calculate alert time: + // - If time pattern exists: 2 hours before peak, clamped to 8:00-22:00 + // - If day-only pattern: send at 8:00 AM (early morning heads-up) + var alertHour = pattern.PeakHour.HasValue + ? Math.Clamp(pattern.PeakHour.Value - 2, 8, 22) + : DefaultMorningHour; + var alertTime = new TimeOnly(alertHour, 0); + + // Check if we're within the 5-minute send window + var diffMinutes = (userTimeNow - alertTime).TotalMinutes; + if (diffMinutes < 0 || diffMinutes >= 5) continue; + + // Check weekly idempotency (Monday of current week) + var daysToMonday = ((int)userToday.DayOfWeek - 1 + 7) % 7; + var weekStart = userToday.AddDays(-daysToMonday); + + var alreadySent = await dbContext.SentSlipAlerts + .AnyAsync(a => a.HabitId == habit.Id && a.WeekStart == weekStart, ct); + if (alreadySent) continue; + + // Generate AI message + var lang = user.Language ?? "en"; + var messageResult = await messageService.GenerateMessageAsync( + habit.Title, pattern.DayOfWeek, pattern.PeakHour, lang, ct); + + if (messageResult.IsFailure) + { + logger.LogWarning("Failed to generate slip alert message for habit {HabitId}", habit.Id); + continue; + } + + var (title, body) = messageResult.Value; + + await pushService.SendToUserAsync(habit.UserId, title, body, "/", ct); + + // Record sent alert + create in-app notification + var sentAlert = SentSlipAlert.Create(habit.Id, weekStart); + await dbContext.SentSlipAlerts.AddAsync(sentAlert, ct); + + var notification = Notification.Create(habit.UserId, title, body, "/", habit.Id); + await dbContext.Notifications.AddAsync(notification, ct); + + await dbContext.SaveChangesAsync(ct); + + logger.LogInformation("Sent slip alert for habit {HabitId} to user {UserId}", habit.Id, habit.UserId); + } + } +} diff --git a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs index ff8d6e6c..de6aa546 100644 --- a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs +++ b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs @@ -172,6 +172,7 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr : $"Every {habit.FrequencyQuantity} {habit.FrequencyUnit.ToString()!.ToLower()}s"; var badHabitLabel = habit.IsBadHabit ? " | BAD HABIT (tracking to avoid)" : ""; + var slipAlertLabel = habit.SlipAlertEnabled ? " | SLIP ALERTS ON" : ""; var completedLabel = habit.IsCompleted ? " | COMPLETED" : ""; var tagsLabel = habit.Tags.Count > 0 ? $" | Tags: [{string.Join(", ", habit.Tags.Select(t => t.Name))}]" : ""; @@ -188,7 +189,7 @@ 31. AssignTags action requires habitId and tagNames array. An empty tagNames arr } var dueTimeLabel = habit.DueTime.HasValue ? $" at {habit.DueTime.Value:HH:mm}" : ""; - sb.AppendLine($"- \"{habit.Title}\" | ID: {habit.Id} | Frequency: {freqLabel} | Due: {habit.DueDate:yyyy-MM-dd}{dueTimeLabel}{badHabitLabel}{completedLabel}{tagsLabel}{metricsLabel}"); + sb.AppendLine($"- \"{habit.Title}\" | ID: {habit.Id} | Frequency: {freqLabel} | Due: {habit.DueDate:yyyy-MM-dd}{dueTimeLabel}{badHabitLabel}{slipAlertLabel}{completedLabel}{tagsLabel}{metricsLabel}"); foreach (var child in habit.Children) { @@ -347,7 +348,7 @@ CreateHabit with subHabits -- "Create workout plan with gym MWF and cardio TuTh" { "actions": [{ "type": "CreateHabit", "title": "Workout Plan", "frequencyUnit": "Day", "frequencyQuantity": 1, "subHabits": [{ "title": "Gym", "days": ["Monday","Wednesday","Friday"] }, { "title": "Cardio", "days": ["Tuesday","Thursday"] }], "dueDate": "2026-02-08" }], "aiMessage": "Created your Workout Plan!" } CreateHabit (bad habit) -- "I want to stop smoking" - { "actions": [{ "type": "CreateHabit", "title": "Smoking", "frequencyUnit": "Day", "frequencyQuantity": 1, "isBadHabit": true, "dueDate": "2026-02-08" }], "aiMessage": "Tracking smoking as a bad habit. Log each slip-up so we can see your progress!" } + { "actions": [{ "type": "CreateHabit", "title": "Smoking", "frequencyUnit": "Day", "frequencyQuantity": 1, "isBadHabit": true, "slipAlertEnabled": true, "dueDate": "2026-02-08" }], "aiMessage": "Tracking smoking as a bad habit with slip alerts enabled. Log each slip-up and I'll send you motivational nudges before your usual slip times!" } CreateHabit (one-time task) -- "Buy eggs tomorrow" { "actions": [{ "type": "CreateHabit", "title": "Buy Eggs", "dueDate": "2026-02-09" }], "aiMessage": "Got it, buy eggs tomorrow!" } @@ -389,7 +390,7 @@ CreateHabit with subHabits -- "Create workout plan with gym MWF and cardio TuTh" ### Action Types & Required Fields: - CreateHabit: type, title, dueDate (YYYY-MM-DD, REQUIRED), dueTime (optional - HH:mm 24h format, e.g. "15:00" for 3pm, ONLY include when user mentions a specific time), frequencyUnit (Day | Week | Month | Year - OMIT for one-time tasks), frequencyQuantity (integer - OMIT for one-time tasks), description (optional), days (optional - only when frequencyQuantity is 1), isBadHabit (optional, true for habits to avoid/stop), tagNames (optional - array of tag name strings, ONLY when user explicitly asks to tag it), subHabits (optional - array of sub-habit OBJECTS, each with: title (REQUIRED), plus optional frequencyUnit, frequencyQuantity, days, dueDate, description, isBadHabit. Sub-habits INHERIT parent frequency/dueDate when those fields are omitted.) + CreateHabit: type, title, dueDate (YYYY-MM-DD, REQUIRED), dueTime (optional - HH:mm 24h format, e.g. "15:00" for 3pm, ONLY include when user mentions a specific time), frequencyUnit (Day | Week | Month | Year - OMIT for one-time tasks), frequencyQuantity (integer - OMIT for one-time tasks), description (optional), days (optional - only when frequencyQuantity is 1), isBadHabit (optional, true for habits to avoid/stop), slipAlertEnabled (optional, defaults to true when isBadHabit is true -- sends AI-generated motivational alerts before predicted slip windows), tagNames (optional - array of tag name strings, ONLY when user explicitly asks to tag it), subHabits (optional - array of sub-habit OBJECTS, each with: title (REQUIRED), plus optional frequencyUnit, frequencyQuantity, days, dueDate, description, isBadHabit. Sub-habits INHERIT parent frequency/dueDate when those fields are omitted.) LogHabit: type, habitId, note (optional - include if user shares context/feelings) SuggestBreakdown: type, title (parent habit name), description (optional), frequencyUnit, frequencyQuantity, dueDate, suggestedSubHabits (array of habit objects with type: "CreateHabit", title, description, frequencyUnit, frequencyQuantity, dueDate) UpdateHabit: type, habitId (REQUIRED - ID of existing habit), title (optional - new title), description (optional), frequencyUnit (optional), frequencyQuantity (optional), days (optional), isBadHabit (optional), dueDate (optional - new due date YYYY-MM-DD), dueTime (optional - HH:mm 24h format to set or change time). Only include fields that are changing.