diff --git a/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs index 21a5f7b3..4197f7ed 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs @@ -11,6 +11,7 @@ namespace Orbit.Application.Chat.Tools.Implementations; public class UpdateHabitTool( IGenericRepository habitRepository, + IUserDateService userDateService, IPayGateService? payGate = null) : IAiTool { public string Name => "update_habit"; @@ -96,7 +97,8 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (habit is null) return HabitToolHelpers.HabitNotFoundResult(habitId); - var updateParams = ResolveUpdateParams(args, habit); + var today = await userDateService.GetUserTodayAsync(userId, ct); + var updateParams = ResolveUpdateParams(args, habit, today); var result = await HabitReactivationAllowance.ExecuteAsync( userId, @@ -119,7 +121,7 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel /// Resolve each field: absent = keep existing, null = clear, value = update. /// Extracted to reduce ExecuteAsync cognitive complexity. /// - private static HabitUpdateParams ResolveUpdateParams(JsonElement args, Habit habit) + private static HabitUpdateParams ResolveUpdateParams(JsonElement args, Habit habit, DateOnly today) { var title = ResolveTitle(args, habit); var description = ResolveDescription(args, habit); @@ -148,7 +150,8 @@ private static HabitUpdateParams ResolveUpdateParams(JsonElement args, Habit hab EndDate: endDate, ClearEndDate: clearEndDate, ScheduledReminders: scheduledReminders, - Emoji: ResolveEmoji(args, habit)); + Emoji: ResolveEmoji(args, habit), + UserToday: today); } private static string ResolveTitle(JsonElement args, Habit habit) => diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index 40a14b84..990a942d 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -70,6 +70,7 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c } var opts = request.Options ?? new UpdateHabitCommandOptions(); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); var result = await HabitReactivationAllowance.ExecuteAsync( request.UserId, @@ -99,7 +100,8 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c EndDate: opts.EndDate, ClearEndDate: request.ClearEndDate, ScheduledReminders: opts.ScheduledReminders, - Emoji: request.Emoji)), + Emoji: request.Emoji, + UserToday: today)), cancellationToken); if (result.IsFailure) @@ -117,7 +119,6 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c await unitOfWork.SaveChangesAsync(cancellationToken); - var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); diff --git a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs index deed8370..2097a954 100644 --- a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs @@ -21,7 +21,7 @@ public static HabitMetrics Calculate( TimeZoneInfo? userTimeZone = null) { var logDates = logs.Where(l => l.Value > 0).Select(l => l.Date).Distinct().ToHashSet(); - var expectedDates = GenerateExpectedDates(habit, today, userTimeZone).ToList(); + var expectedDates = GenerateExpectedDates(habit, logs, today, userTimeZone).ToList(); var currentStreak = CalculateCurrentStreak(habit, expectedDates, logDates, today); var longestStreak = CalculateLongestStreak(habit, expectedDates, logDates); @@ -49,10 +49,16 @@ public static DateOnly GetUserToday(User user) return DateOnly.FromDateTime(userNow); } - private static List GenerateExpectedDates(Habit habit, DateOnly today, TimeZoneInfo? userTimeZone = null) + private static List GenerateExpectedDates( + Habit habit, + IReadOnlyCollection logs, + DateOnly today, + TimeZoneInfo? userTimeZone = null) { var tz = userTimeZone ?? TimeZoneInfo.Utc; - var habitStartDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(habit.CreatedAtUtc, tz)); + var createdDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(habit.CreatedAtUtc, tz)); + var habitStartDate = habit.ScheduledStartDate + ?? ResolveLegacyStartDate(habit, logs, createdDate); if (habit.FrequencyUnit is null || habit.FrequencyQuantity is null) return [habitStartDate]; @@ -63,6 +69,45 @@ private static List GenerateExpectedDates(Habit habit, DateOnly today, return GenerateFrequencyBasedDates(habit, today, habitStartDate); } + private static DateOnly ResolveLegacyStartDate( + Habit habit, + IReadOnlyCollection logs, + DateOnly createdDate) + { + var hasProgressingHistory = HasProgressingLegacyHistory( + habit, + logs, + createdDate); + return hasProgressingHistory ? createdDate : habit.DueDate; + } + + private static bool HasProgressingLegacyHistory( + Habit habit, + IReadOnlyCollection logs, + DateOnly createdDate) + { + if (habit.FrequencyUnit is null || habit.IsBadHabit) + return false; + + var resolvedDates = logs + .Where(log => !log.IsDeleted) + .Select(log => log.Date) + .ToHashSet(); + var firstCandidate = createdDate < habit.DueDate.AddDays(-MaxStreakHorizonDays) + ? habit.DueDate.AddDays(-MaxStreakHorizonDays) + : createdDate; + var candidateCount = habit.DueDate.DayNumber - firstCandidate.DayNumber; + if (candidateCount <= 0) + return false; + + var expectedBeforeDue = Enumerable.Range(0, candidateCount) + .Select(firstCandidate.AddDays) + .Where(date => HabitScheduleService.IsHabitHistoricallyDueOnDate(habit, date, createdDate)) + .ToList(); + + return expectedBeforeDue.Count > 0 && expectedBeforeDue.All(resolvedDates.Contains); + } + private static List GenerateDayFilteredDates(Habit habit, DateOnly today, DateOnly startDate) { var expectedDates = new List(); diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index 9119f09c..d2db9c4b 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -49,7 +49,8 @@ public record HabitUpdateParams( DateOnly? EndDate = null, bool? ClearEndDate = null, IReadOnlyList? ScheduledReminders = null, - string? Emoji = null); + string? Emoji = null, + DateOnly? UserToday = null); public class Habit : Entity, ITimestamped, ISoftDeletable { @@ -62,6 +63,7 @@ public class Habit : Entity, ITimestamped, ISoftDeletable public bool IsBadHabit { get; private set; } public bool IsCompleted { get; private set; } public DateOnly DueDate { get; private set; } + public DateOnly? ScheduledStartDate { get; private set; } /// /// For monthly/yearly habits, the original day-of-month from the first DueDate (1-31). /// Preserves the anchor across month-end clamping so a Jan 31 habit re-anchors to Mar 31 @@ -146,6 +148,7 @@ public static Result Create(HabitCreateParams p) IsGeneral = p.IsGeneral, IsFlexible = p.IsFlexible, DueDate = p.DueDate, + ScheduledStartDate = p.DueDate, OriginalDayOfMonth = p.FrequencyUnit is Enums.FrequencyUnit.Month or Enums.FrequencyUnit.Year ? p.DueDate.Day : null, @@ -193,6 +196,8 @@ public Result Log(DateOnly date, string? note = null, bool advanceDueD public void AdvanceDueDate(DateOnly today) { + CaptureLegacyScheduledStart(); + do { var prev = DueDate; @@ -214,6 +219,9 @@ public void AdvanceDueDate(DateOnly today) /// public void CatchUpDueDate(DateOnly today) { + if (DueDate < today && !IsCompleted) + CaptureLegacyScheduledStart(); + while (DueDate < today && !IsCompleted) { var prev = DueDate; @@ -230,6 +238,12 @@ public void CatchUpDueDate(DateOnly today) UpdatedAtUtc = DateTime.UtcNow; } + private void CaptureLegacyScheduledStart() + { + if (ScheduledStartDate is null) + ScheduledStartDate = DueDate; + } + /// /// Advances DueDate by one frequency step, re-anchoring for monthly/yearly drift /// and snapping to the next matching day-of-week if Days are set. @@ -368,7 +382,17 @@ private void ApplyRequiredUpdates(HabitUpdateParams p) DueEndTime = p.DueEndTime; if (p.DueDate is not null) + { + var reschedulesUnstartedHabit = ScheduledStartDate.HasValue + && p.UserToday.HasValue + && ScheduledStartDate.Value >= p.UserToday.Value + && DueDate == ScheduledStartDate.Value + && p.DueDate.Value != DueDate; + DueDate = p.DueDate.Value; + if (reschedulesUnstartedHabit) + ScheduledStartDate = DueDate; + } if (FrequencyUnit is Enums.FrequencyUnit.Month or Enums.FrequencyUnit.Year) OriginalDayOfMonth = DueDate.Day; diff --git a/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.Designer.cs new file mode 100644 index 00000000..947e66ef --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.Designer.cs @@ -0,0 +1,2606 @@ +// +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("20260807025751_AddHabitScheduledStartDate")] + partial class AddHabitScheduledStartDate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + 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.AccountabilityCheckIn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PairId", "CreatedAtUtc"); + + b.HasIndex("PairId", "UserId", "Date") + .IsUnique(); + + b.ToTable("AccountabilityCheckIns"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("AccountabilityPairs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("PairId", "UserId", "HabitId") + .IsUnique(); + + b.ToTable("AccountabilityPairHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InputFileId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OutputFileId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AiFactExtractionBatches"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiUsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CachedTokens") + .HasColumnType("bigint"); + + b.Property("Calls") + .HasColumnType("bigint"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("CostUsd") + .HasColumnType("numeric"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Date", "Model", "Purpose") + .IsUnique(); + + b.ToTable("AiUsageDaily"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + 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" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("BlockedUsers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PeriodEndUtc") + .HasColumnType("date"); + + b.Property("PeriodStartUtc") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeId") + .HasColumnType("uuid"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ChallengeId", "UserId") + .IsUnique() + .HasFilter("\"LeftAtUtc\" IS NULL"); + + b.ToTable("ChallengeParticipants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeParticipantId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("ChallengeParticipantId", "HabitId") + .IsUnique(); + + b.ToTable("ChallengeParticipantHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Items") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId", "CreatedAtUtc"); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId", "AchievementId") + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + + b.HasIndex("ActorUserId", "CreatedAtUtc", "Id") + .IsDescending(false, true, true); + + b.HasIndex("ActorUserId", "Type", "Value") + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + + b.ToTable("FriendFeedEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.HasIndex("GoalId", "IsDeleted"); + + b.HasIndex("GoalId", "UpdatedAtUtc"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("ScheduledStartDate") + .HasColumnType("date"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + 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("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "UpdatedAtUtc"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + 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("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("UserId", "IdempotencyKey", "RequestType") + .IsUnique(); + + b.ToTable("ProcessedRequests"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedStripeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("ProcessedStripeEvents"); + }); + + 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.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReportedUserId") + .HasColumnType("uuid"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CheerId"); + + b.HasIndex("ReportedUserId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentProactiveCheckin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("SentProactiveCheckins"); + }); + + 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("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("When") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When"), false); + + 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.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique() + .HasFilter("\"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + 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("ColorScheme") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("Handle") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedOnboardingChecklist") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasCreatedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("HasLoggedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasSeenImportPrompt") + .HasColumnType("boolean"); + + b.Property("HasTriedAstra") + .HasColumnType("boolean"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("MarketingConsentUpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarketingEmailConsent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + b.Property("ProactiveAstraEnabled") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowAchievements") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowLevel") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowStreak") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowTopHabits") + .HasColumnType("boolean"); + + b.Property("PublicProfileSlug") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SocialOptIn") + .HasColumnType("boolean"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimeZone") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("PlayPurchaseToken") + .IsUnique() + .HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + + b.HasIndex("PublicProfileSlug") + .IsUnique() + .HasFilter("\"PublicProfileSlug\" IS NOT NULL"); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + 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("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.XpAwardLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("AwardedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AwardedAtUtc"); + + b.ToTable("XpAwardLogs"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.AccountabilityCheckIn", b => + { + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.HasOne("Orbit.Domain.Entities.Challenge", null) + .WithMany("Participants") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.HasOne("Orbit.Domain.Entities.ChallengeParticipant", null) + .WithMany("LinkedHabits") + .HasForeignKey("ChallengeParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RecipientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .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); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedRequest", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.HasOne("Orbit.Domain.Entities.Cheer", null) + .WithMany() + .HasForeignKey("CheerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReportedUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentProactiveCheckin", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.XpAwardLog", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Navigation("LinkedHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + 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/20260807025751_AddHabitScheduledStartDate.cs b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs new file mode 100644 index 00000000..36ed1903 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddHabitScheduledStartDate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ScheduledStartDate", + table: "Habits", + type: "date", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ScheduledStartDate", + table: "Habits"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index cf73ddfa..b6a46fb3 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -1229,6 +1229,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("jsonb") .HasDefaultValueSql("'[]'::jsonb"); + b.Property("ScheduledStartDate") + .HasColumnType("date"); + b.Property("SlipAlertEnabled") .HasColumnType("boolean"); diff --git a/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs index 26dd9fd3..ef5749cb 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/ChatToolMetadataTests.cs @@ -46,7 +46,7 @@ public void ToolMetadata_ExposesExpectedNamesDescriptionsAndSchemas() var updateGoalProgressTool = new UpdateGoalProgressTool(Repo(), Repo(), unitOfWork); var updateGoalStatusTool = new UpdateGoalStatusTool(Repo(), gamificationService, unitOfWork, logger); var updateGoalTool = new UpdateGoalTool(Repo(), unitOfWork); - var updateHabitTool = new UpdateHabitTool(Repo()); + var updateHabitTool = new UpdateHabitTool(Repo(), userDateService); var listTagsTool = new ListTagsTool(mediator); var createTagTool = new CreateTagTool(mediator); var updateTagTool = new UpdateTagTool(mediator); diff --git a/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs index 89c5fd2f..3caca10a 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs @@ -17,6 +17,7 @@ namespace Orbit.Application.Tests.Chat.Tools; public class UpdateHabitToolTests { private readonly IGenericRepository _habitRepo = Substitute.For>(); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly UpdateHabitTool _tool; private static readonly Guid UserId = Guid.NewGuid(); @@ -24,7 +25,9 @@ public class UpdateHabitToolTests public UpdateHabitToolTests() { - _tool = new UpdateHabitTool(_habitRepo); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) + .Returns(Today); + _tool = new UpdateHabitTool(_habitRepo, _userDateService); } [Fact] @@ -47,7 +50,7 @@ public async Task AtCapReactivation_ReturnsPayGateFailureWithoutMutatingHabit() var payGate = Substitute.For(); payGate.CanCreateHabits(UserId, 1, Arg.Any()) .Returns(Result.PayGateFailure("Habit limit reached")); - var tool = new UpdateHabitTool(_habitRepo, payGate); + var tool = new UpdateHabitTool(_habitRepo, _userDateService, payGate); var args = JsonDocument.Parse($$$"""{"habit_id": "{{{habit.Id}}}", "end_date": null}""").RootElement; var result = await tool.ExecuteAsync(args, UserId, CancellationToken.None); @@ -66,7 +69,7 @@ public async Task AllowedReactivation_UpdatesCompletedHabit() var payGate = Substitute.For(); payGate.CanCreateHabits(UserId, 1, Arg.Any()) .Returns(Result.Success()); - var tool = new UpdateHabitTool(_habitRepo, payGate); + var tool = new UpdateHabitTool(_habitRepo, _userDateService, payGate); var args = JsonDocument.Parse($$$"""{"habit_id": "{{{habit.Id}}}", "end_date": null}""").RootElement; var result = await tool.ExecuteAsync(args, UserId, CancellationToken.None); @@ -593,7 +596,7 @@ public async Task WrongUser_CannotUpdateAnothersHabit_OwnerCan_RealContext() } await using var context = CreateContext(databaseName); - var tool = new UpdateHabitTool(new GenericRepository(context)); + var tool = new UpdateHabitTool(new GenericRepository(context), _userDateService); var attackerId = Guid.NewGuid(); var attackerResult = await tool.ExecuteAsync(RenameArgs(habitId, "Hijacked"), attackerId, CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs index fcd0915d..0bcfb5d7 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs @@ -109,8 +109,7 @@ public async Task Handle_WithLinkedStreakGoal_UsesMinimumStreakAcrossAllLinkedHa "Read", FrequencyUnit.Day, 1, - DueDate: Today)).Value; - SetCreatedAtUtc(completedHabit, Today.AddDays(-3)); + DueDate: Today.AddDays(-3))).Value; completedHabit.Log(Today.AddDays(-3), advanceDueDate: false); completedHabit.Log(Today.AddDays(-2), advanceDueDate: false); completedHabit.Log(Today.AddDays(-1), advanceDueDate: false); @@ -121,8 +120,7 @@ public async Task Handle_WithLinkedStreakGoal_UsesMinimumStreakAcrossAllLinkedHa FrequencyUnit.Day, 1, IsBadHabit: true, - DueDate: Today)).Value; - SetCreatedAtUtc(badHabit, Today.AddDays(-1)); + DueDate: Today.AddDays(-1))).Value; completedHabit.AddGoal(goal); badHabit.AddGoal(goal); @@ -186,7 +184,7 @@ public async Task Handle_LinkedStreakGoalReachesTarget_FiresGamificationOnce() UserId, "3-day streak", 3, "days", Type: GoalType.Streak)).Value; var habit = Habit.Create(new HabitCreateParams( - UserId, "Meditate", FrequencyUnit.Day, 1, DueDate: Today)).Value; + UserId, "Meditate", FrequencyUnit.Day, 1, DueDate: Today.AddDays(-2))).Value; SetCreatedAtUtc(habit, Today.AddDays(-2)); habit.Log(Today.AddDays(-2), advanceDueDate: false); habit.Log(Today.AddDays(-1), advanceDueDate: false); @@ -220,7 +218,7 @@ public async Task Handle_LinkedStreakGoalAlreadyCompleted_DoesNotFireGamificatio goal.Status.Should().Be(GoalStatus.Completed); var habit = Habit.Create(new HabitCreateParams( - UserId, "Meditate", FrequencyUnit.Day, 1, DueDate: Today)).Value; + UserId, "Meditate", FrequencyUnit.Day, 1, DueDate: Today.AddDays(-2))).Value; SetCreatedAtUtc(habit, Today.AddDays(-2)); habit.Log(Today.AddDays(-2), advanceDueDate: false); habit.Log(Today.AddDays(-1), advanceDueDate: false); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/SkipHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/SkipHabitCommandHandlerTests.cs index 8347be66..d3122f95 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/SkipHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/SkipHabitCommandHandlerTests.cs @@ -36,13 +36,6 @@ public SkipHabitCommandHandlerTests() .Returns(Today); } - private static void SetCreatedAtUtc(Habit habit, DateOnly localDate) - { - typeof(Habit) - .GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, localDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } - [Fact] public async Task Handle_OneTimeTask_PostponesToTomorrow() { @@ -213,8 +206,7 @@ public async Task Handle_WithLinkedStreakGoal_UsesMinimumStreakAcrossAllLinkedHa "Read", FrequencyUnit.Day, 1, - DueDate: Today)).Value; - SetCreatedAtUtc(skippedHabit, Today.AddDays(-3)); + DueDate: Today.AddDays(-3))).Value; skippedHabit.Log(Today.AddDays(-3), advanceDueDate: false); skippedHabit.Log(Today.AddDays(-2), advanceDueDate: false); skippedHabit.Log(Today.AddDays(-1), advanceDueDate: false); @@ -225,8 +217,7 @@ public async Task Handle_WithLinkedStreakGoal_UsesMinimumStreakAcrossAllLinkedHa FrequencyUnit.Day, 1, IsBadHabit: true, - DueDate: Today)).Value; - SetCreatedAtUtc(badHabit, Today.AddDays(-1)); + DueDate: Today.AddDays(-1))).Value; skippedHabit.AddGoal(goal); badHabit.AddGoal(goal); diff --git a/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs b/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs index de5608f2..7d7a6ab5 100644 --- a/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs +++ b/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs @@ -52,9 +52,10 @@ private static Habit CreateHabit() => /// private static Habit CreateHabitWithStreak(int streakDays) { - var habit = Habit.Create(new HabitCreateParams(UserId, "Habit", FrequencyUnit.Day, 1, Today)).Value; + var startDate = Today.AddDays(-400); + var habit = Habit.Create(new HabitCreateParams(UserId, "Habit", FrequencyUnit.Day, 1, startDate)).Value; typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, Today.AddDays(-400).ToDateTime(TimeOnly.MinValue)); + .SetValue(habit, startDate.ToDateTime(TimeOnly.MinValue)); for (var day = 0; day < streakDays; day++) habit.Log(Today.AddDays(-day), advanceDueDate: false); return habit; @@ -67,9 +68,8 @@ private static Habit CreateHabitWithStreak(int streakDays) private static Habit CreateBadHabitWithAbstinenceStreak() { var habit = Habit.Create( - new HabitCreateParams(UserId, "Bad Habit", FrequencyUnit.Day, 1, Today, IsBadHabit: true)).Value; - typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, Today.AddDays(-400).ToDateTime(TimeOnly.MinValue)); + new HabitCreateParams( + UserId, "Bad Habit", FrequencyUnit.Day, 1, Today.AddDays(-400), IsBadHabit: true)).Value; return habit; } diff --git a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalByIdQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalByIdQueryHandlerTests.cs index 17bcc497..8b0ff480 100644 --- a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalByIdQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalByIdQueryHandlerTests.cs @@ -41,13 +41,6 @@ private void ArrangeGoal(Goal? goal) .Returns((goal is null ? new List() : [goal]).AsReadOnly()); } - private static void SetCreatedAtUtc(Habit habit, DateOnly localDate) - { - typeof(Habit) - .GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, localDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } - [Fact] public async Task Handle_GoalFound_ReturnsSuccess() { @@ -160,9 +153,9 @@ public async Task Handle_WithBadHabitLinkedStreakGoal_ReturnsFreshCurrentValueWi UserId, "Avoid doom scrolling", 7, "days", Type: GoalType.Streak)).Value; var badHabit = Habit.Create(new HabitCreateParams( - UserId, "Doom scrolling", FrequencyUnit.Day, 1, IsBadHabit: true, DueDate: Today)).Value; + UserId, "Doom scrolling", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(-1))).Value; - SetCreatedAtUtc(badHabit, Today.AddDays(-1)); badHabit.AddGoal(goal); goal.AddHabit(badHabit); ArrangeGoal(goal); diff --git a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalDetailQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalDetailQueryHandlerTests.cs index fa20d4e3..5fc78f40 100644 --- a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalDetailQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalDetailQueryHandlerTests.cs @@ -41,13 +41,6 @@ private void ArrangeGoal(Goal? goal) .Returns((goal is null ? new List() : [goal]).AsReadOnly()); } - private static void SetCreatedAtUtc(Habit habit, DateOnly localDate) - { - typeof(Habit) - .GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, localDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } - [Fact] public async Task Handle_GoalFound_ReturnsDetailWithMetrics() { @@ -139,9 +132,8 @@ public async Task Handle_WithBadHabitLinkedStreakGoal_ReturnsFreshCurrentValueWi FrequencyUnit.Day, 1, IsBadHabit: true, - DueDate: Today)).Value; + DueDate: Today.AddDays(-1))).Value; - SetCreatedAtUtc(badHabit, Today.AddDays(-1)); badHabit.AddGoal(goal); goal.AddHabit(badHabit); ArrangeGoal(goal); diff --git a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalMetricsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalMetricsQueryHandlerTests.cs index ffd27c34..0880bac3 100644 --- a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalMetricsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalMetricsQueryHandlerTests.cs @@ -41,13 +41,6 @@ private void ArrangeGoal(Goal? goal) .Returns((goal is null ? new List() : [goal]).AsReadOnly()); } - private static void SetCreatedAtUtc(Habit habit, DateOnly localDate) - { - typeof(Habit) - .GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, localDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } - [Fact] public async Task Handle_GoalFound_ReturnsMetrics() { @@ -120,9 +113,8 @@ public async Task Handle_WithBadHabitLinkedStreakGoal_CalculatesMetricsFromFresh FrequencyUnit.Day, 1, IsBadHabit: true, - DueDate: Today)).Value; + DueDate: Today.AddDays(-1))).Value; - SetCreatedAtUtc(badHabit, Today.AddDays(-1)); badHabit.AddGoal(goal); goal.AddHabit(badHabit); ArrangeGoal(goal); diff --git a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalReviewQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalReviewQueryHandlerTests.cs index f94dce6a..28a46e41 100644 --- a/tests/Orbit.Application.Tests/Queries/Goals/GetGoalReviewQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Goals/GetGoalReviewQueryHandlerTests.cs @@ -35,13 +35,6 @@ private static Goal CreateTestGoal() return Goal.Create(UserId, "Active Goal", 100, "pages").Value; } - private static void SetCreatedAtUtc(Habit habit, DateOnly localDate) - { - typeof(Habit) - .GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, localDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } - [Fact] public async Task Handle_GeneratesNewReview_WhenNotCached() { @@ -75,8 +68,8 @@ public async Task Handle_RefreshesStreakGoalValue_BeforeBuildingContext() var streakGoal = Goal.Create(new Goal.CreateGoalParams( UserId, "Avoid doom scrolling", 7, "days", Type: GoalType.Streak)).Value; var badHabit = Habit.Create(new HabitCreateParams( - UserId, "Doom scrolling", FrequencyUnit.Day, 1, IsBadHabit: true, DueDate: Today)).Value; - SetCreatedAtUtc(badHabit, Today.AddDays(-3)); + UserId, "Doom scrolling", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(-3))).Value; badHabit.AddGoal(streakGoal); streakGoal.AddHabit(badHabit); diff --git a/tests/Orbit.Application.Tests/Services/GoalStreakSyncServiceTests.cs b/tests/Orbit.Application.Tests/Services/GoalStreakSyncServiceTests.cs index 42fe8922..9ddf9797 100644 --- a/tests/Orbit.Application.Tests/Services/GoalStreakSyncServiceTests.cs +++ b/tests/Orbit.Application.Tests/Services/GoalStreakSyncServiceTests.cs @@ -19,7 +19,7 @@ private static Goal CreateStreakGoal(decimal target = 7) private static Habit CreateDailyHabit(string title, DateOnly createdOn, bool isBadHabit = false) { var habit = Habit.Create(new HabitCreateParams( - UserId, title, FrequencyUnit.Day, 1, IsBadHabit: isBadHabit, DueDate: Today)).Value; + UserId, title, FrequencyUnit.Day, 1, IsBadHabit: isBadHabit, DueDate: createdOn)).Value; SetCreatedAtUtc(habit, createdOn); return habit; } @@ -124,6 +124,18 @@ public void CalculateCurrentStreak_BadHabitQuietDays_CountsUnloggedDays() GoalStreakSyncService.CalculateCurrentStreak(goal, Today).Should().Be(3); } + [Fact] + public void CalculateCurrentStreak_FutureBadHabit_ReturnsZero() + { + var goal = CreateStreakGoal(30); + var habit = Habit.Create(new HabitCreateParams( + UserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(5))).Value; + goal.AddHabit(habit); + + GoalStreakSyncService.CalculateCurrentStreak(goal, Today).Should().Be(0); + } + [Fact] public void SyncCurrentStreak_UpdatesProgressAndTimestamp_ReturnsTrue() { diff --git a/tests/Orbit.Application.Tests/Services/Goals/StreakGoalReadSyncerTests.cs b/tests/Orbit.Application.Tests/Services/Goals/StreakGoalReadSyncerTests.cs index e9a425f1..71ad04de 100644 --- a/tests/Orbit.Application.Tests/Services/Goals/StreakGoalReadSyncerTests.cs +++ b/tests/Orbit.Application.Tests/Services/Goals/StreakGoalReadSyncerTests.cs @@ -27,21 +27,14 @@ private static Goal CreateBadHabitStreakGoal(decimal target) UserId, "Avoid doom scrolling", target, "days", Type: GoalType.Streak)).Value; var badHabit = Habit.Create(new HabitCreateParams( - UserId, "Doom scrolling", FrequencyUnit.Day, 1, IsBadHabit: true, DueDate: Today)).Value; + UserId, "Doom scrolling", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(-1))).Value; - SetCreatedAtUtc(badHabit, Today.AddDays(-1)); badHabit.AddGoal(goal); goal.AddHabit(badHabit); return goal; } - private static void SetCreatedAtUtc(Habit habit, DateOnly localDate) - { - typeof(Habit) - .GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, localDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } - private void ArrangeGoals(params Goal[] goals) { _goalRepo.FindAsync( diff --git a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index 338c1d79..9607409e 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -249,6 +249,389 @@ public void Calculate_BadHabit_NoLogs_CompletionRate100() metrics.WeeklyCompletionRate.Should().Be(100); } + [Fact] + public void Calculate_BadHabitScheduledInFuture_HasNoMetricsYet() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(5))).Value; + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Fact] + public void Calculate_GoodHabitScheduledInFuture_HasNoMetricsYet() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Morning walk", FrequencyUnit.Day, 1, + DueDate: Today.AddDays(5))).Value; + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Fact] + public void Calculate_LegacyBadHabitScheduledInFuture_HasNoMetricsYet() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(5))).Value; + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Fact] + public void Calculate_LegacyGoodHabitScheduledInFutureWithPreStartLog_HasNoMetricsYet() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Morning walk", FrequencyUnit.Day, 1, + DueDate: Today.AddDays(5))).Value; + habit.Log(Today, advanceDueDate: false); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + public void Calculate_LegacyBadHabitScheduledForNextOccurrenceWithPreStartLog_HasNoMetricsYet( + int frequencyQuantity) + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, frequencyQuantity, + IsBadHabit: true, DueDate: Today.AddDays(1))).Value; + habit.Log(Today, advanceDueDate: false); + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, Today.AddDays(-5).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Fact] + public void Calculate_BadHabitScheduledToday_StartsWithOneCleanDay() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today)).Value; + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(1); + metrics.LongestStreak.Should().Be(1); + metrics.WeeklyCompletionRate.Should().Be(100); + metrics.MonthlyCompletionRate.Should().Be(100); + } + + [Fact] + public void Calculate_FutureBadHabitRescheduledEarlier_StartsOnNewDate() + { + var newStart = Today.AddDays(2); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(5))).Value; + + habit.Update(new HabitUpdateParams( + habit.Title, + habit.Description, + habit.FrequencyUnit, + habit.FrequencyQuantity, + habit.Days.ToList(), + habit.IsBadHabit, + newStart, + UserToday: Today)); + + var metrics = HabitMetricsCalculator.Calculate(habit, newStart); + + habit.ScheduledStartDate.Should().Be(newStart); + metrics.CurrentStreak.Should().Be(1); + metrics.LongestStreak.Should().Be(1); + } + + [Fact] + public void Calculate_FutureBadHabitRescheduledLater_HasNoPreStartMetrics() + { + var newStart = Today.AddDays(5); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(2))).Value; + + habit.Update(new HabitUpdateParams( + habit.Title, + habit.Description, + habit.FrequencyUnit, + habit.FrequencyQuantity, + habit.Days.ToList(), + habit.IsBadHabit, + newStart, + UserToday: Today)); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today.AddDays(3)); + + habit.ScheduledStartDate.Should().Be(newStart); + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Fact] + public void Calculate_BadHabitStartingTodayRescheduledEarlier_UsesNewStart() + { + var newStart = Today.AddDays(-1); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today)).Value; + + habit.Update(new HabitUpdateParams( + habit.Title, + habit.Description, + habit.FrequencyUnit, + habit.FrequencyQuantity, + habit.Days.ToList(), + habit.IsBadHabit, + newStart, + UserToday: Today)); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + habit.ScheduledStartDate.Should().Be(newStart); + metrics.CurrentStreak.Should().Be(2); + metrics.LongestStreak.Should().Be(2); + } + + [Fact] + public void Calculate_BadHabitStartingTodayRescheduledLater_HasNoPreStartMetrics() + { + var newStart = Today.AddDays(2); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today)).Value; + + habit.Update(new HabitUpdateParams( + habit.Title, + habit.Description, + habit.FrequencyUnit, + habit.FrequencyQuantity, + habit.Days.ToList(), + habit.IsBadHabit, + newStart, + UserToday: Today)); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today.AddDays(1)); + + habit.ScheduledStartDate.Should().Be(newStart); + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Fact] + public void Calculate_LegacyBadHabitWithCleanHistory_PreservesHistoricalWindow() + { + var startDate = Today.AddDays(-5); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: startDate)).Value; + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + habit.CatchUpDueDate(Today); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(6); + metrics.LongestStreak.Should().Be(6); + metrics.WeeklyCompletionRate.Should().Be(100); + metrics.MonthlyCompletionRate.Should().Be(100); + } + + [Fact] + public void Calculate_LegacyBadHabitFirstScheduledToday_StartsToday() + { + var createdDate = Today.AddDays(-5); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today)).Value; + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, createdDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(1); + metrics.LongestStreak.Should().Be(1); + metrics.WeeklyCompletionRate.Should().Be(100); + metrics.MonthlyCompletionRate.Should().Be(100); + } + + [Fact] + public void Calculate_LegacyBadHabitAfterFirstScheduledDate_ExcludesPreStartDays() + { + var scheduledStart = Today.AddDays(1); + var evaluationDate = scheduledStart.AddDays(1); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: scheduledStart)).Value; + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, Today.AddDays(-5).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + var metrics = HabitMetricsCalculator.Calculate(habit, evaluationDate); + + metrics.CurrentStreak.Should().Be(2); + metrics.LongestStreak.Should().Be(2); + metrics.WeeklyCompletionRate.Should().Be(100); + metrics.MonthlyCompletionRate.Should().Be(100); + } + + [Fact] + public void Calculate_LegacyBadHabitLoggedOnDueDate_CapturesStartBeforeAdvance() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today)).Value; + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, Today.AddDays(-5).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + var logResult = habit.Log(Today); + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + logResult.IsSuccess.Should().BeTrue(); + habit.ScheduledStartDate.Should().Be(Today); + habit.DueDate.Should().Be(Today.AddDays(1)); + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + } + + [Theory] + [InlineData(FrequencyUnit.Week)] + [InlineData(FrequencyUnit.Month)] + public void Calculate_LegacyBadHabitCaughtUpIntoFuture_PreservesHistory(FrequencyUnit unit) + { + var scheduledStart = Today.AddDays(-5); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", unit, 1, + IsBadHabit: true, DueDate: scheduledStart)).Value; + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, scheduledStart.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + habit.CatchUpDueDate(Today); + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + habit.DueDate.Should().BeAfter(Today); + habit.ScheduledStartDate.Should().Be(scheduledStart); + metrics.CurrentStreak.Should().BeGreaterThan(0); + metrics.LongestStreak.Should().BeGreaterThan(0); + metrics.WeeklyCompletionRate.Should().Be(100); + metrics.MonthlyCompletionRate.Should().Be(100); + } + + [Fact] + public void Calculate_LegacyGoodHabitWithOnePreStartLog_DoesNotInferProgress() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Morning walk", FrequencyUnit.Day, 1, + DueDate: Today)).Value; + habit.Log(Today.AddDays(-1), advanceDueDate: false); + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, Today.AddDays(-5).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + + [Fact] + public void Calculate_HabitStartedYesterday_ExcludesEarlierDates() + { + var yesterday = Today.AddDays(-1); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Morning walk", FrequencyUnit.Day, 1, + DueDate: yesterday)).Value; + habit.Log(yesterday, advanceDueDate: false); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(1); + metrics.LongestStreak.Should().Be(1); + metrics.WeeklyCompletionRate.Should().Be(50); + metrics.MonthlyCompletionRate.Should().Be(50); + } + + [Fact] + public void Calculate_AdvancedDueDate_PreservesOriginalExpectedWindow() + { + var startDate = Today.AddDays(-3); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Morning walk", FrequencyUnit.Day, 1, + DueDate: startDate)).Value; + + for (var date = startDate; date <= Today; date = date.AddDays(1)) + habit.Log(date); + + habit.DueDate.Should().Be(Today.AddDays(1)); + typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); + typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! + .SetValue(habit, startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(4); + metrics.LongestStreak.Should().Be(4); + metrics.WeeklyCompletionRate.Should().Be(100); + metrics.MonthlyCompletionRate.Should().Be(100); + } + + [Fact] + public void Calculate_BadHabitWithPreStartLog_HasNoMetricsYet() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "No caffeine", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(5))).Value; + habit.Log(Today, advanceDueDate: false); + + var metrics = HabitMetricsCalculator.Calculate(habit, Today); + + metrics.CurrentStreak.Should().Be(0); + metrics.LongestStreak.Should().Be(0); + metrics.WeeklyCompletionRate.Should().Be(0); + metrics.MonthlyCompletionRate.Should().Be(0); + } + [Fact] public void Calculate_BadHabit_LoggedToday_CompletionRate0() { @@ -506,9 +889,7 @@ public void Calculate_MonthlyRate_100WhenLogged() public void Calculate_StreakLongerThan365Days_NotCappedAt365() { var habit = Habit.Create(new HabitCreateParams( - ValidUserId, "Long Streak", FrequencyUnit.Day, 1, DueDate: Today)).Value; - typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, Today.AddDays(-600).ToDateTime(TimeOnly.MinValue)); + ValidUserId, "Long Streak", FrequencyUnit.Day, 1, DueDate: Today.AddDays(-600))).Value; for (var day = 0; day < 500; day++) habit.Log(Today.AddDays(-day), advanceDueDate: false); diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs new file mode 100644 index 00000000..21f7a7ae --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using FluentAssertions; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Orbit.Infrastructure.Migrations; + +namespace Orbit.Infrastructure.Tests.Persistence; + +public class AddHabitScheduledStartDateMigrationTests +{ + [Fact] + public void Up_LeavesAmbiguousLegacyRowsNullable() + { + var migration = new AddHabitScheduledStartDate(); + var builder = new MigrationBuilder("Npgsql.EntityFrameworkCore.PostgreSQL"); + typeof(AddHabitScheduledStartDate) + .GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(migration, [builder]); + + var operation = builder.Operations.OfType().Should().ContainSingle().Subject; + + operation.Name.Should().Be("ScheduledStartDate"); + operation.IsNullable.Should().BeTrue(); + builder.Operations.Should().NotContain(operation => operation is SqlOperation); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoalDeadlineNotificationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoalDeadlineNotificationServiceTests.cs index 98ad3640..71af5e19 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/GoalDeadlineNotificationServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/GoalDeadlineNotificationServiceTests.cs @@ -46,7 +46,7 @@ public void FormatDeadlineBody_OneDayBefore_English_ReturnsTomorrowMessage() var goal = CreateGoal(); goal.UpdateProgress(5); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,1, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 1, "en"); body.Should().Contain("due tomorrow"); body.Should().Contain("5/10 km"); @@ -58,7 +58,7 @@ public void FormatDeadlineBody_OneDayBefore_Portuguese_ReturnsTomorrowMessage() var goal = CreateGoal(); goal.UpdateProgress(3); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,1, "pt-br"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 1, "pt-br"); body.Should().Contain("amanhã"); body.Should().Contain("3/10 km"); @@ -69,7 +69,7 @@ public void FormatDeadlineBody_ThreeDaysBefore_English_ReturnsDaysMessage() { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,3, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 3, "en"); body.Should().Contain("due in 3 days"); } @@ -79,7 +79,7 @@ public void FormatDeadlineBody_SevenDaysBefore_English_ReturnsDaysMessage() { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,7, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 7, "en"); body.Should().Contain("due in 7 days"); } @@ -89,7 +89,7 @@ public void FormatDeadlineBody_ThreeDaysBefore_Portuguese_ReturnsDaysMessage() { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,3, "pt-br"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 3, "pt-br"); body.Should().Contain("termina em 3 dias"); } @@ -99,7 +99,7 @@ public void FormatDeadlineBody_SevenDaysBefore_Portuguese_ReturnsDaysMessage() { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,7, "pt"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 7, "pt"); body.Should().Contain("termina em 7 dias"); } @@ -110,7 +110,7 @@ public void FormatDeadlineBody_IncludesProgressText() var goal = CreateGoal(targetValue: 100, unit: "pages"); goal.UpdateProgress(42); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,3, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 3, "en"); body.Should().Contain("42/100 pages"); } @@ -120,7 +120,7 @@ public void FormatDeadlineBody_ZeroProgress_ShowsZero() { var goal = CreateGoal(targetValue: 50, unit: "sessions"); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,1, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 1, "en"); body.Should().Contain("0/50 sessions"); } @@ -132,7 +132,7 @@ public void FormatDeadlineBody_ZeroProgress_ShowsZero() public void FormatDeadlineBody_VariousDays_English_FormatsCorrectly(int days, string lang, string expected) { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,days, lang); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, days, lang); body.Should().Contain(expected); } @@ -143,7 +143,7 @@ public void FormatDeadlineBody_VariousDays_English_FormatsCorrectly(int days, st public void FormatDeadlineBody_VariousDays_Portuguese_FormatsCorrectly(int days, string lang, string expected) { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,days, lang); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, days, lang); body.Should().Contain(expected); } @@ -151,7 +151,7 @@ public void FormatDeadlineBody_VariousDays_Portuguese_FormatsCorrectly(int days, public void FormatDeadlineBody_OneDayBefore_English_DoesNotContainDaysPlural() { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,1, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 1, "en"); body.Should().Contain("tomorrow"); body.Should().NotContain("in 1 days"); @@ -161,7 +161,7 @@ public void FormatDeadlineBody_OneDayBefore_English_DoesNotContainDaysPlural() public void FormatDeadlineBody_OneDayBefore_Portuguese_DoesNotContainDiasPlural() { var goal = CreateGoal(); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,1, "pt-br"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 1, "pt-br"); body.Should().Contain("amanhã"); body.Should().NotContain("em 1 dias"); @@ -173,7 +173,7 @@ public void FormatDeadlineBody_DecimalProgress_FormatsCorrectly() var goal = CreateGoal(targetValue: 10, unit: "miles"); goal.UpdateProgress(3.5m); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,3, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 3, "en"); body.Should().Contain("3.5/10 miles"); } @@ -184,7 +184,7 @@ public void FormatDeadlineBody_LargeTargetValue_FormatsCorrectly() var goal = CreateGoal(targetValue: 10000, unit: "steps"); goal.UpdateProgress(5000); - var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue,7, "en"); + var body = GoalDeadlineNotificationService.FormatDeadlineBody(goal, goal.CurrentValue, 7, "en"); body.Should().Contain("5000/10000 steps"); } @@ -259,7 +259,7 @@ public async Task CheckAndSendDeadlineNotifications_BadHabitStreakGoal_NotifiesW var goal = Goal.Create(new Goal.CreateGoalParams( user.Id, "Avoid doom scrolling", 7, "days", Deadline: Today.AddDays(1), Type: GoalType.Streak)).Value; - var badHabit = CreateBadHabitDueToday(user.Id); + var badHabit = CreateBadHabitStartedYesterday(user.Id); goal.AddHabit(badHabit); dbContext.Users.Add(user); @@ -293,7 +293,7 @@ public async Task CheckAndSendDeadlineNotifications_StreakGoalFreshlyAtTarget_Se var goal = Goal.Create(new Goal.CreateGoalParams( user.Id, "Avoid doom scrolling", 2, "days", Deadline: Today.AddDays(1), Type: GoalType.Streak)).Value; - var badHabit = CreateBadHabitDueToday(user.Id); + var badHabit = CreateBadHabitStartedYesterday(user.Id); goal.AddHabit(badHabit); dbContext.Users.Add(user); @@ -510,21 +510,14 @@ await pushService.Received(1).SendToUserAsync( return (user, goal); } - private static Habit CreateBadHabitDueToday(Guid userId) + private static Habit CreateBadHabitStartedYesterday(Guid userId) { var habit = Habit.Create(new HabitCreateParams( - userId, "Doom scrolling", FrequencyUnit.Day, 1, IsBadHabit: true, DueDate: Today)).Value; - SetCreatedAtUtc(habit, Today.AddDays(-1)); + userId, "Doom scrolling", FrequencyUnit.Day, 1, + IsBadHabit: true, DueDate: Today.AddDays(-1))).Value; return habit; } - private static void SetCreatedAtUtc(Habit habit, DateOnly localDate) - { - typeof(Habit) - .GetProperty(nameof(Habit.CreatedAtUtc))! - .SetValue(habit, localDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); - } - private static OrbitDbContext CreateInMemoryDbContext() { var options = new DbContextOptionsBuilder() diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakGoalSyncServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakGoalSyncServiceTests.cs index 38cc0d8a..f93cd954 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/StreakGoalSyncServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakGoalSyncServiceTests.cs @@ -141,9 +141,10 @@ public async Task SyncActiveStreakGoals_NoLinkedHabits_LeavesGoalUntouched() private static Habit CreateDailyHabitLoggedLastDays(Guid userId, int days) { + var startDate = Today.AddDays(-(days - 1)); var habit = Habit.Create(new HabitCreateParams( - userId, "Meditate", FrequencyUnit.Day, 1, DueDate: Today)).Value; - SetCreatedAtUtc(habit, Today.AddDays(-(days - 1))); + userId, "Meditate", FrequencyUnit.Day, 1, DueDate: startDate)).Value; + SetCreatedAtUtc(habit, startDate); for (var offset = days - 1; offset >= 0; offset--) habit.Log(Today.AddDays(-offset), advanceDueDate: false); return habit;