From 6259bc6707fdb204e62b317924bc5b9c00f014ae Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Thu, 6 Aug 2026 23:54:06 -0300 Subject: [PATCH 01/12] chore: open ORB-127 implementation From 04246cfeb33d8190c8a6af51438dc21eb7572deb Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 00:01:08 -0300 Subject: [PATCH 02/12] fix: start habit metrics on scheduled date --- .../Habits/Services/HabitMetricsCalculator.cs | 24 +- src/Orbit.Domain/Entities/Habit.cs | 2 + ...751_AddHabitScheduledStartDate.Designer.cs | 2606 +++++++++++++++++ ...260807025751_AddHabitScheduledStartDate.cs | 29 + .../Migrations/OrbitDbContextModelSnapshot.cs | 5 +- .../Services/GoalStreakSyncServiceTests.cs | 14 +- .../Services/HabitMetricsCalculatorTests.cs | 122 +- 7 files changed, 2794 insertions(+), 8 deletions(-) create mode 100644 src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs diff --git a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs index deed8370..f3ea8f9d 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, today, createdDate); if (habit.FrequencyUnit is null || habit.FrequencyQuantity is null) return [habitStartDate]; @@ -63,6 +69,18 @@ private static List GenerateExpectedDates(Habit habit, DateOnly today, return GenerateFrequencyBasedDates(habit, today, habitStartDate); } + private static DateOnly ResolveLegacyStartDate( + Habit habit, + IReadOnlyCollection logs, + DateOnly today, + DateOnly createdDate) + { + var hasCompletedLog = logs.Any(log => log.Value > 0); + return !hasCompletedLog && habit.DueDate > today + ? habit.DueDate + : createdDate; + } + 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 1ec8ae60..d8d9ac48 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -62,6 +62,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 +147,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, 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/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/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index 338c1d79..9adef988 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -249,6 +249,124 @@ 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_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_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 +624,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); From 9da7a36f38ef4ab8c18d36f66f808273478ed31a Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 11:39:29 -0300 Subject: [PATCH 03/12] fix: preserve legacy habit streak history --- src/Orbit.Domain/Entities/Habit.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index d8d9ac48..b7eaf8c7 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -82,7 +82,18 @@ public class Habit : Entity, ITimestamped, ISoftDeletable public DateOnly? EndDate { get; private set; } public int? Position { get; private set; } public string? GoogleEventId { get; private set; } - public DateTime CreatedAtUtc { get; private set; } + private DateTime _createdAtUtc; + public DateTime CreatedAtUtc + { + get => _createdAtUtc; + private set + { + if (_createdAtUtc != default) + ScheduledStartDate = null; + + _createdAtUtc = value; + } + } public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; public bool IsDeleted { get; private set; } public DateTime? DeletedAtUtc { get; private set; } From 81517914177a735ff28751fe49368a4e97c8ada5 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 12:09:43 -0300 Subject: [PATCH 04/12] fix: distinguish legacy habit progress --- .../Habits/Services/HabitMetricsCalculator.cs | 41 +++++++++++++++++-- .../Services/HabitMetricsCalculatorTests.cs | 17 ++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs index f3ea8f9d..0f790c25 100644 --- a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs @@ -75,10 +75,43 @@ private static DateOnly ResolveLegacyStartDate( DateOnly today, DateOnly createdDate) { - var hasCompletedLog = logs.Any(log => log.Value > 0); - return !hasCompletedLog && habit.DueDate > today - ? habit.DueDate - : createdDate; + if (habit.DueDate <= today) + return createdDate; + + var hasProgressingLog = HasProgressingLegacyLog(habit, logs, today, createdDate); + return hasProgressingLog ? createdDate : habit.DueDate; + } + + private static bool HasProgressingLegacyLog( + Habit habit, + IReadOnlyCollection logs, + DateOnly today, + DateOnly createdDate) + { + if (habit.FrequencyUnit is null) + return false; + + var completedDates = logs + .Where(log => log.Value > 0) + .Select(log => log.Date) + .ToHashSet(); + var hasCompletedHistory = completedDates.Any(date => + date <= today + && HabitScheduleService.IsHabitHistoricallyDueOnDate(habit, date, createdDate)); + + if (!hasCompletedHistory) + return false; + + var futureStart = today.AddDays(1); + if (futureStart >= habit.DueDate) + return true; + + var futureExpectedDates = habit.Days.Count > 0 && habit.FrequencyQuantity == 1 + ? GenerateDayFilteredDates(habit, habit.DueDate.AddDays(-1), futureStart) + : GenerateFrequencyBasedDates(habit, habit.DueDate, futureStart) + .Where(date => date < habit.DueDate); + + return futureExpectedDates.All(completedDates.Contains); } private static List GenerateDayFilteredDates(Habit habit, DateOnly today, DateOnly startDate) diff --git a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index 9adef988..1e90cccc 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -295,6 +295,23 @@ public void Calculate_LegacyBadHabitScheduledInFuture_HasNoMetricsYet() 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); + } + [Fact] public void Calculate_BadHabitScheduledToday_StartsWithOneCleanDay() { From fadd9c0485089c6f1cbb59757a49a06450cdfe9a Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 12:46:42 -0300 Subject: [PATCH 05/12] fix: validate legacy due pointer progress --- .../Habits/Services/HabitMetricsCalculator.cs | 18 ++++---- .../Habits/LogHabitLinkedGoalTests.cs | 6 +-- .../Habits/SkipHabitCommandHandlerTests.cs | 13 +----- .../AchievementProgressServiceTests.cs | 5 +-- .../Goals/GetGoalByIdQueryHandlerTests.cs | 11 +---- .../Goals/GetGoalDetailQueryHandlerTests.cs | 10 +---- .../Goals/GetGoalMetricsQueryHandlerTests.cs | 10 +---- .../Goals/GetGoalReviewQueryHandlerTests.cs | 11 +---- .../Goals/StreakGoalReadSyncerTests.cs | 11 +---- .../Services/HabitMetricsCalculatorTests.cs | 38 ++++++++++++++++ .../GoalDeadlineNotificationServiceTests.cs | 45 ++++++++----------- 11 files changed, 79 insertions(+), 99 deletions(-) diff --git a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs index 0f790c25..fb2cb1f2 100644 --- a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs @@ -75,9 +75,6 @@ private static DateOnly ResolveLegacyStartDate( DateOnly today, DateOnly createdDate) { - if (habit.DueDate <= today) - return createdDate; - var hasProgressingLog = HasProgressingLegacyLog(habit, logs, today, createdDate); return hasProgressingLog ? createdDate : habit.DueDate; } @@ -88,7 +85,7 @@ private static bool HasProgressingLegacyLog( DateOnly today, DateOnly createdDate) { - if (habit.FrequencyUnit is null) + if (habit.FrequencyUnit is null || habit.IsBadHabit) return false; var completedDates = logs @@ -97,21 +94,22 @@ private static bool HasProgressingLegacyLog( .ToHashSet(); var hasCompletedHistory = completedDates.Any(date => date <= today + && date < habit.DueDate && HabitScheduleService.IsHabitHistoricallyDueOnDate(habit, date, createdDate)); if (!hasCompletedHistory) return false; - var futureStart = today.AddDays(1); - if (futureStart >= habit.DueDate) + if (habit.DueDate <= today) return true; var futureExpectedDates = habit.Days.Count > 0 && habit.FrequencyQuantity == 1 - ? GenerateDayFilteredDates(habit, habit.DueDate.AddDays(-1), futureStart) - : GenerateFrequencyBasedDates(habit, habit.DueDate, futureStart) - .Where(date => date < habit.DueDate); + ? GenerateDayFilteredDates(habit, habit.DueDate.AddDays(-1), today) + : GenerateFrequencyBasedDates(habit, habit.DueDate, today) + .Where(date => date < habit.DueDate) + .ToList(); - return futureExpectedDates.All(completedDates.Contains); + return futureExpectedDates.Count > 0 && futureExpectedDates.All(completedDates.Contains); } private static List GenerateDayFilteredDates(Habit habit, DateOnly today, DateOnly startDate) diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs index fcd0915d..936348e0 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); 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..0464b573 100644 --- a/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs +++ b/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs @@ -67,9 +67,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/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 1e90cccc..cdb61854 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -312,6 +312,27 @@ public void Calculate_LegacyGoodHabitScheduledInFutureWithPreStartLog_HasNoMetri 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)); + + 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() { @@ -327,6 +348,23 @@ public void Calculate_BadHabitScheduledToday_StartsWithOneCleanDay() metrics.MonthlyCompletionRate.Should().Be(100); } + [Fact] + public void Calculate_LegacyBadHabitCreatedEarlierAndScheduledToday_StartsToday() + { + 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)); + + 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_HabitStartedYesterday_ExcludesEarlierDates() { 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() From b61e8c6124cd190f71f0bb0e18b940ca970bdaf0 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 13:11:17 -0300 Subject: [PATCH 06/12] fix: preserve verified legacy habit history --- .../Habits/Services/HabitMetricsCalculator.cs | 61 +++++++++++-------- .../Services/HabitMetricsCalculatorTests.cs | 43 ++++++++++++- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs index fb2cb1f2..e457a970 100644 --- a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs @@ -57,8 +57,9 @@ private static List GenerateExpectedDates( { var tz = userTimeZone ?? TimeZoneInfo.Utc; var createdDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(habit.CreatedAtUtc, tz)); + var updatedDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(habit.UpdatedAtUtc, tz)); var habitStartDate = habit.ScheduledStartDate - ?? ResolveLegacyStartDate(habit, logs, today, createdDate); + ?? ResolveLegacyStartDate(habit, logs, today, createdDate, updatedDate); if (habit.FrequencyUnit is null || habit.FrequencyQuantity is null) return [habitStartDate]; @@ -73,43 +74,55 @@ private static DateOnly ResolveLegacyStartDate( Habit habit, IReadOnlyCollection logs, DateOnly today, - DateOnly createdDate) + DateOnly createdDate, + DateOnly updatedDate) { - var hasProgressingLog = HasProgressingLegacyLog(habit, logs, today, createdDate); - return hasProgressingLog ? createdDate : habit.DueDate; + var hasProgressingHistory = HasProgressingLegacyHistory( + habit, + logs, + today, + createdDate, + updatedDate); + return hasProgressingHistory ? createdDate : habit.DueDate; } - private static bool HasProgressingLegacyLog( + private static bool HasProgressingLegacyHistory( Habit habit, IReadOnlyCollection logs, DateOnly today, - DateOnly createdDate) + DateOnly createdDate, + DateOnly updatedDate) { - if (habit.FrequencyUnit is null || habit.IsBadHabit) + if (habit.FrequencyUnit is null) return false; - var completedDates = logs - .Where(log => log.Value > 0) + if (habit.IsBadHabit) + { + if (habit.DueDate < today) + return true; + + return habit.DueDate == today + && updatedDate >= habit.DueDate + && updatedDate > createdDate; + } + + var resolvedDates = logs + .Where(log => !log.IsDeleted) .Select(log => log.Date) .ToHashSet(); - var hasCompletedHistory = completedDates.Any(date => - date <= today - && date < habit.DueDate - && HabitScheduleService.IsHabitHistoricallyDueOnDate(habit, date, createdDate)); - - if (!hasCompletedHistory) + var firstCandidate = createdDate < habit.DueDate.AddDays(-MaxStreakHorizonDays) + ? habit.DueDate.AddDays(-MaxStreakHorizonDays) + : createdDate; + var candidateCount = habit.DueDate.DayNumber - firstCandidate.DayNumber; + if (candidateCount <= 0) return false; - if (habit.DueDate <= today) - return true; - - var futureExpectedDates = habit.Days.Count > 0 && habit.FrequencyQuantity == 1 - ? GenerateDayFilteredDates(habit, habit.DueDate.AddDays(-1), today) - : GenerateFrequencyBasedDates(habit, habit.DueDate, today) - .Where(date => date < habit.DueDate) - .ToList(); + var expectedBeforeDue = Enumerable.Range(0, candidateCount) + .Select(firstCandidate.AddDays) + .Where(date => HabitScheduleService.IsHabitHistoricallyDueOnDate(habit, date, createdDate)) + .ToList(); - return futureExpectedDates.Count > 0 && futureExpectedDates.All(completedDates.Contains); + return expectedBeforeDue.Count > 0 && expectedBeforeDue.All(resolvedDates.Contains); } private static List GenerateDayFilteredDates(Habit habit, DateOnly today, DateOnly startDate) diff --git a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index cdb61854..a43feec8 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -349,13 +349,34 @@ public void Calculate_BadHabitScheduledToday_StartsWithOneCleanDay() } [Fact] - public void Calculate_LegacyBadHabitCreatedEarlierAndScheduledToday_StartsToday() + 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)); + 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, Today.AddDays(-5).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + .SetValue(habit, createdDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + habit.UpdatedAtUtc = habit.CreatedAtUtc; var metrics = HabitMetricsCalculator.Calculate(habit, Today); @@ -365,6 +386,24 @@ public void Calculate_LegacyBadHabitCreatedEarlierAndScheduledToday_StartsToday( 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)); + + 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() { From 23803a7664ede236b11d3f28ffc1f929718423dc Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 13:18:35 -0300 Subject: [PATCH 07/12] test: mark legacy habit fixtures explicitly --- src/Orbit.Domain/Entities/Habit.cs | 13 +------------ .../Services/HabitMetricsCalculatorTests.cs | 4 ++++ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index b7eaf8c7..d8d9ac48 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -82,18 +82,7 @@ public class Habit : Entity, ITimestamped, ISoftDeletable public DateOnly? EndDate { get; private set; } public int? Position { get; private set; } public string? GoogleEventId { get; private set; } - private DateTime _createdAtUtc; - public DateTime CreatedAtUtc - { - get => _createdAtUtc; - private set - { - if (_createdAtUtc != default) - ScheduledStartDate = null; - - _createdAtUtc = value; - } - } + public DateTime CreatedAtUtc { get; private set; } public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; public bool IsDeleted { get; private set; } public DateTime? DeletedAtUtc { get; private set; } diff --git a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index a43feec8..5b1c57b3 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -324,6 +324,7 @@ public void Calculate_LegacyBadHabitScheduledForNextOccurrenceWithPreStartLog_Ha 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); @@ -357,6 +358,7 @@ public void Calculate_LegacyBadHabitWithCleanHistory_PreservesHistoricalWindow() 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); @@ -376,6 +378,7 @@ public void Calculate_LegacyBadHabitFirstScheduledToday_StartsToday() 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); habit.UpdatedAtUtc = habit.CreatedAtUtc; var metrics = HabitMetricsCalculator.Calculate(habit, Today); @@ -395,6 +398,7 @@ public void Calculate_LegacyGoodHabitWithOnePreStartLog_DoesNotInferProgress() 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); From 488f81d6f76873cde43aa8a540c9a13fe60500ea Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 13:22:37 -0300 Subject: [PATCH 08/12] test: retain explicit scheduled starts --- .../Commands/Habits/LogHabitLinkedGoalTests.cs | 4 ++-- .../Gamification/AchievementProgressServiceTests.cs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs index 936348e0..0bcfb5d7 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitLinkedGoalTests.cs @@ -184,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); @@ -218,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/Gamification/AchievementProgressServiceTests.cs b/tests/Orbit.Application.Tests/Gamification/AchievementProgressServiceTests.cs index 0464b573..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; From c9500e8bf6901c2e30b375b294321b06877afce1 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 13:47:29 -0300 Subject: [PATCH 09/12] fix: reschedule future habit metrics --- .../Tools/Implementations/UpdateHabitTool.cs | 11 ++-- .../Habits/Commands/UpdateHabitCommand.cs | 5 +- src/Orbit.Domain/Entities/Habit.cs | 13 ++++- .../Chat/Tools/ChatToolMetadataTests.cs | 2 +- .../Chat/Tools/UpdateHabitToolTests.cs | 7 ++- .../Services/HabitMetricsCalculatorTests.cs | 52 +++++++++++++++++++ .../Services/StreakGoalSyncServiceTests.cs | 5 +- 7 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs index 4c648d83..b4ece87f 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs @@ -9,7 +9,8 @@ namespace Orbit.Application.Chat.Tools.Implementations; public class UpdateHabitTool( - IGenericRepository habitRepository) : IAiTool + IGenericRepository habitRepository, + IUserDateService userDateService) : IAiTool { public string Name => "update_habit"; @@ -94,7 +95,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 = habit.Update(updateParams); if (result.IsFailure) @@ -107,7 +109,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); @@ -136,7 +138,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 cebbb788..3b485535 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 = habit.Update(new HabitUpdateParams( request.Title, @@ -90,7 +91,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)); if (result.IsFailure) return result; @@ -107,7 +109,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.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index d8d9ac48..557ad5b2 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 { @@ -370,7 +371,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/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 47dbd282..3d23e854 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs @@ -16,6 +16,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(); @@ -23,7 +24,9 @@ public class UpdateHabitToolTests public UpdateHabitToolTests() { - _tool = new UpdateHabitTool(_habitRepo); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) + .Returns(Today); + _tool = new UpdateHabitTool(_habitRepo, _userDateService); } [Fact] @@ -555,7 +558,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/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index 5b1c57b3..362ff845 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -349,6 +349,58 @@ public void Calculate_BadHabitScheduledToday_StartsWithOneCleanDay() 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_LegacyBadHabitWithCleanHistory_PreservesHistoricalWindow() { 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; From dfcaa24ca5561e9c8e7913f89639e03b8b868056 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 13:57:45 -0300 Subject: [PATCH 10/12] fix: preserve proven legacy bad habit starts --- .../Habits/Services/HabitMetricsCalculator.cs | 27 +++-------- src/Orbit.Domain/Entities/Habit.cs | 3 ++ .../Services/HabitMetricsCalculatorTests.cs | 45 ++++++++++++++++++- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs index e457a970..2097a954 100644 --- a/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/HabitMetricsCalculator.cs @@ -57,9 +57,8 @@ private static List GenerateExpectedDates( { var tz = userTimeZone ?? TimeZoneInfo.Utc; var createdDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(habit.CreatedAtUtc, tz)); - var updatedDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(habit.UpdatedAtUtc, tz)); var habitStartDate = habit.ScheduledStartDate - ?? ResolveLegacyStartDate(habit, logs, today, createdDate, updatedDate); + ?? ResolveLegacyStartDate(habit, logs, createdDate); if (habit.FrequencyUnit is null || habit.FrequencyQuantity is null) return [habitStartDate]; @@ -73,39 +72,23 @@ private static List GenerateExpectedDates( private static DateOnly ResolveLegacyStartDate( Habit habit, IReadOnlyCollection logs, - DateOnly today, - DateOnly createdDate, - DateOnly updatedDate) + DateOnly createdDate) { var hasProgressingHistory = HasProgressingLegacyHistory( habit, logs, - today, - createdDate, - updatedDate); + createdDate); return hasProgressingHistory ? createdDate : habit.DueDate; } private static bool HasProgressingLegacyHistory( Habit habit, IReadOnlyCollection logs, - DateOnly today, - DateOnly createdDate, - DateOnly updatedDate) + DateOnly createdDate) { - if (habit.FrequencyUnit is null) + if (habit.FrequencyUnit is null || habit.IsBadHabit) return false; - if (habit.IsBadHabit) - { - if (habit.DueDate < today) - return true; - - return habit.DueDate == today - && updatedDate >= habit.DueDate - && updatedDate > createdDate; - } - var resolvedDates = logs .Where(log => !log.IsDeleted) .Select(log => log.Date) diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index 557ad5b2..63ae54e5 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -217,6 +217,9 @@ public void AdvanceDueDate(DateOnly today) /// public void CatchUpDueDate(DateOnly today) { + if (ScheduledStartDate is null && DueDate < today && !IsCompleted) + ScheduledStartDate = DueDate; + while (DueDate < today && !IsCompleted) { var prev = DueDate; diff --git a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index 362ff845..39721ad7 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -431,7 +431,6 @@ public void Calculate_LegacyBadHabitFirstScheduledToday_StartsToday() typeof(Habit).GetProperty(nameof(Habit.CreatedAtUtc))! .SetValue(habit, createdDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); typeof(Habit).GetProperty(nameof(Habit.ScheduledStartDate))!.SetValue(habit, null); - habit.UpdatedAtUtc = habit.CreatedAtUtc; var metrics = HabitMetricsCalculator.Calculate(habit, Today); @@ -441,6 +440,50 @@ public void Calculate_LegacyBadHabitFirstScheduledToday_StartsToday() 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); + } + + [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() { From c6411e6b4bc95b0297f7b6d5a04b83c7eaf57095 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 14:10:09 -0300 Subject: [PATCH 11/12] fix: seed legacy scheduled starts safely --- src/Orbit.Domain/Entities/Habit.cs | 12 +++++++-- ...260807025751_AddHabitScheduledStartDate.cs | 7 ++++++ .../Services/HabitMetricsCalculatorTests.cs | 20 +++++++++++++++ ...ddHabitScheduledStartDateMigrationTests.cs | 25 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index 63ae54e5..6c8eeed4 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -196,6 +196,8 @@ public Result Log(DateOnly date, string? note = null, bool advanceDueD public void AdvanceDueDate(DateOnly today) { + CaptureLegacyScheduledStart(); + do { var prev = DueDate; @@ -217,8 +219,8 @@ public void AdvanceDueDate(DateOnly today) /// public void CatchUpDueDate(DateOnly today) { - if (ScheduledStartDate is null && DueDate < today && !IsCompleted) - ScheduledStartDate = DueDate; + if (DueDate < today && !IsCompleted) + CaptureLegacyScheduledStart(); while (DueDate < today && !IsCompleted) { @@ -236,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. diff --git a/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs index 36ed1903..afad45a1 100644 --- a/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs +++ b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs @@ -16,6 +16,13 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Habits", type: "date", nullable: true); + + migrationBuilder.Sql( + """ + UPDATE "Habits" + SET "ScheduledStartDate" = "DueDate" + WHERE "ScheduledStartDate" IS NULL; + """); } /// diff --git a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index 39721ad7..c3fb28fb 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -460,6 +460,26 @@ public void Calculate_LegacyBadHabitAfterFirstScheduledDate_ExcludesPreStartDays 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)] diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs new file mode 100644 index 00000000..f0af7b98 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs @@ -0,0 +1,25 @@ +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_SeedsAmbiguousLegacyRowsFromCurrentDueDate() + { + 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.Sql.Should().Contain("SET \"ScheduledStartDate\" = \"DueDate\""); + operation.Sql.Should().Contain("WHERE \"ScheduledStartDate\" IS NULL"); + } +} From 52277ce9355fe9ebf849a7a0f90b7dba6ea692c4 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 14:21:11 -0300 Subject: [PATCH 12/12] fix: preserve ambiguous legacy schedule state --- src/Orbit.Domain/Entities/Habit.cs | 2 +- ...260807025751_AddHabitScheduledStartDate.cs | 7 --- .../Services/HabitMetricsCalculatorTests.cs | 52 +++++++++++++++++++ ...ddHabitScheduledStartDateMigrationTests.cs | 9 ++-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index 6c8eeed4..5a603462 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -385,7 +385,7 @@ private void ApplyRequiredUpdates(HabitUpdateParams p) { var reschedulesUnstartedHabit = ScheduledStartDate.HasValue && p.UserToday.HasValue - && ScheduledStartDate.Value > p.UserToday.Value + && ScheduledStartDate.Value >= p.UserToday.Value && DueDate == ScheduledStartDate.Value && p.DueDate.Value != DueDate; diff --git a/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs index afad45a1..36ed1903 100644 --- a/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs +++ b/src/Orbit.Infrastructure/Migrations/20260807025751_AddHabitScheduledStartDate.cs @@ -16,13 +16,6 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Habits", type: "date", nullable: true); - - migrationBuilder.Sql( - """ - UPDATE "Habits" - SET "ScheduledStartDate" = "DueDate" - WHERE "ScheduledStartDate" IS NULL; - """); } /// diff --git a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs index c3fb28fb..9607409e 100644 --- a/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs +++ b/tests/Orbit.Application.Tests/Services/HabitMetricsCalculatorTests.cs @@ -401,6 +401,58 @@ public void Calculate_FutureBadHabitRescheduledLater_HasNoPreStartMetrics() 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() { diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs index f0af7b98..21f7a7ae 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/AddHabitScheduledStartDateMigrationTests.cs @@ -9,7 +9,7 @@ namespace Orbit.Infrastructure.Tests.Persistence; public class AddHabitScheduledStartDateMigrationTests { [Fact] - public void Up_SeedsAmbiguousLegacyRowsFromCurrentDueDate() + public void Up_LeavesAmbiguousLegacyRowsNullable() { var migration = new AddHabitScheduledStartDate(); var builder = new MigrationBuilder("Npgsql.EntityFrameworkCore.PostgreSQL"); @@ -17,9 +17,10 @@ public void Up_SeedsAmbiguousLegacyRowsFromCurrentDueDate() .GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)! .Invoke(migration, [builder]); - var operation = builder.Operations.OfType().Should().ContainSingle().Subject; + var operation = builder.Operations.OfType().Should().ContainSingle().Subject; - operation.Sql.Should().Contain("SET \"ScheduledStartDate\" = \"DueDate\""); - operation.Sql.Should().Contain("WHERE \"ScheduledStartDate\" IS NULL"); + operation.Name.Should().Be("ScheduledStartDate"); + operation.IsNullable.Should().BeTrue(); + builder.Operations.Should().NotContain(operation => operation is SqlOperation); } }