From c595a2b3ddfe1dbfa0da22d637c984a21c3f88a0 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 26 Sep 2026 14:14:40 -0300 Subject: [PATCH 1/6] Fix relative reminders across due times and daylight saving --- src/Orbit.Api/Controllers/HabitsController.cs | 12 +- .../Controllers/HabitsControllerRequests.cs | 12 +- src/Orbit.Api/Controllers/SyncController.cs | 6 +- src/Orbit.Api/openapi.json | 67 + .../Tools/Implementations/CreateHabitTool.cs | 6 +- .../Implementations/CreateSubHabitTool.cs | 6 +- .../ReminderStoreNormalizer.cs | 63 +- src/Orbit.Application/Common/AppConstants.cs | 1 + .../Commands/BulkCreateHabitsCommand.cs | 4 +- .../Habits/Commands/CreateHabitCommand.cs | 1 + .../Habits/Commands/CreateSubHabitCommand.cs | 1 + .../Habits/Commands/DuplicateHabitCommand.cs | 2 + .../Habits/Commands/HabitCommandOptions.cs | 6 +- .../Habits/Commands/UpdateHabitCommand.cs | 1 + .../Habits/Queries/GetCalendarMonthQuery.cs | 3 +- .../Habits/Queries/GetHabitByIdQuery.cs | 6 +- .../Habits/Queries/GetHabitFullDetailQuery.cs | 3 +- .../Habits/Queries/GetHabitScheduleQuery.cs | 3 +- .../Habits/Queries/HabitScheduleFilters.cs | 3 +- .../BulkCreateHabitsCommandValidator.cs | 1 + .../Validators/CreateHabitCommandValidator.cs | 1 + .../CreateSubHabitCommandValidator.cs | 1 + .../Habits/Validators/SharedHabitRules.cs | 12 + .../Validators/UpdateHabitCommandValidator.cs | 1 + src/Orbit.Domain/Common/DomainConstants.cs | 1 + src/Orbit.Domain/Common/DomainErrors.cs | 1 + src/Orbit.Domain/Entities/Habit.cs | 68 +- src/Orbit.Domain/Entities/HabitInvariants.cs | 17 + .../ValueObjects/RelativeReminderTime.cs | 12 + ..._FoldDueTimeScheduledReminders.Designer.cs | 2705 +++++++++++++++++ ...926170829_FoldDueTimeScheduledReminders.cs | 57 + .../Migrations/OrbitDbContextModelSnapshot.cs | 6 + .../Persistence/OrbitDbContext.cs | 8 + .../Services/ReminderSchedulerService.cs | 58 +- .../Services/SchedulerProjections.cs | 1 + .../Chat/Tools/UpdateHabitToolTests.cs | 22 +- .../BulkUpdateHabitsCommandHandlerTests.cs | 10 +- .../CreateHabitCommandValidatorTests.cs | 31 + .../Orbit.Domain.Tests/Entities/HabitTests.cs | 54 + .../ValueObjects/RelativeReminderTimeTests.cs | 22 + .../Services/ReminderSchedulerServiceTests.cs | 76 + 41 files changed, 3263 insertions(+), 108 deletions(-) create mode 100644 src/Orbit.Domain/ValueObjects/RelativeReminderTime.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260926170829_FoldDueTimeScheduledReminders.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260926170829_FoldDueTimeScheduledReminders.cs create mode 100644 tests/Orbit.Domain.Tests/ValueObjects/RelativeReminderTimeTests.cs diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index 5f3a637ce..475fc7509 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -209,7 +209,8 @@ public async Task CreateHabit( ChecklistItems: request.ChecklistItems, ScheduledReminders: request.ScheduledReminders, EndDate: request.EndDate, - IsFlexible: request.IsFlexible), + IsFlexible: request.IsFlexible, + RelativeReminders: request.RelativeReminders), TagIds: request.TagIds, GoalIds: request.GoalIds, Emoji: request.Emoji, @@ -307,7 +308,8 @@ public async Task UpdateHabit( ChecklistItems: request.ChecklistItems, ScheduledReminders: request.ScheduledReminders, EndDate: request.EndDate, - IsFlexible: request.IsFlexible), + IsFlexible: request.IsFlexible, + RelativeReminders: request.RelativeReminders), GoalIds: request.GoalIds, Emoji: request.Emoji, IntervalWeeks: request.IntervalWeeks); @@ -555,7 +557,8 @@ public async Task CreateSubHabit( ChecklistItems: request.ChecklistItems, ScheduledReminders: request.ScheduledReminders, EndDate: request.EndDate, - IsFlexible: request.IsFlexible), + IsFlexible: request.IsFlexible, + RelativeReminders: request.RelativeReminders), TagIds: request.TagIds, Emoji: request.Emoji); @@ -605,7 +608,8 @@ private static BulkHabitItem MapToBulkHabitItem(BulkHabitItemRequest request) ChecklistItems: request.ChecklistItems, GoogleEventId: request.GoogleEventId, Tags: request.Tags, - IntervalWeeks: request.IntervalWeeks); + IntervalWeeks: request.IntervalWeeks, + RelativeReminders: request.RelativeReminders); } [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Habit created {HabitId} by user {UserId}")] diff --git a/src/Orbit.Api/Controllers/HabitsControllerRequests.cs b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs index bd56815f3..15d5b3684 100644 --- a/src/Orbit.Api/Controllers/HabitsControllerRequests.cs +++ b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs @@ -27,7 +27,8 @@ public record CreateHabitRequest( bool IsFlexible = false, IReadOnlyList? GoalIds = null, string? Emoji = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public record UpdateHabitRequest( string Title, @@ -50,7 +51,8 @@ public record UpdateHabitRequest( bool? IsFlexible = null, IReadOnlyList? GoalIds = null, string? Emoji = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public record UpdateChecklistRequest(IReadOnlyList ChecklistItems); @@ -83,7 +85,8 @@ public record BulkHabitItemRequest( string? GoogleEventId = null, string? Emoji = null, IReadOnlyList? Tags = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public record BulkDeleteHabitsRequest(IReadOnlyList HabitIds); @@ -132,7 +135,8 @@ public record CreateSubHabitRequest( IReadOnlyList? TagIds = null, DateOnly? EndDate = null, bool IsFlexible = false, - string? Emoji = null); + string? Emoji = null, + IReadOnlyList? RelativeReminders = null); public record LinkGoalsRequest(List GoalIds); diff --git a/src/Orbit.Api/Controllers/SyncController.cs b/src/Orbit.Api/Controllers/SyncController.cs index 3fd764502..256ca0cbc 100644 --- a/src/Orbit.Api/Controllers/SyncController.cs +++ b/src/Orbit.Api/Controllers/SyncController.cs @@ -58,7 +58,8 @@ public record SyncHabitDto( DateTime CreatedAtUtc, DateTime UpdatedAtUtc, string? Emoji = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public record SyncHabitLogDto(Guid Id, Guid HabitId, DateOnly Date, decimal Value, DateTime CreatedAtUtc, DateTime UpdatedAtUtc); @@ -346,7 +347,8 @@ private static SyncHabitDto MapHabit(Habit habit) habit.CreatedAtUtc, habit.UpdatedAtUtc, Emoji: habit.Emoji, - IntervalWeeks: habit.IntervalWeeks); + IntervalWeeks: habit.IntervalWeeks, + RelativeReminders: habit.RelativeReminders); } private static SyncHabitLogDto MapHabitLog(HabitLog habitLog) diff --git a/src/Orbit.Api/openapi.json b/src/Orbit.Api/openapi.json index dda222f08..54707cc17 100644 --- a/src/Orbit.Api/openapi.json +++ b/src/Orbit.Api/openapi.json @@ -13577,6 +13577,15 @@ "string" ], "format": "int32" + }, + "relativeReminders": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/RelativeReminderTime" + } } } }, @@ -14149,6 +14158,15 @@ "string" ], "format": "int32" + }, + "relativeReminders": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/RelativeReminderTime" + } } } }, @@ -14286,6 +14304,15 @@ "null", "string" ] + }, + "relativeReminders": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/RelativeReminderTime" + } } } }, @@ -15240,6 +15267,37 @@ } } }, + "RelativeReminderTime": { + "type": "object", + "properties": { + "minutesBefore": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + }, + "when": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ScheduledReminderWhen" + } + ] + }, + "time": { + "type": [ + "null", + "string" + ], + "format": "time" + } + } + }, "ReorderGoalsRequest": { "required": [ "positions" @@ -16200,6 +16258,15 @@ "string" ], "format": "int32" + }, + "relativeReminders": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/RelativeReminderTime" + } } } }, diff --git a/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs index b8a03c76d..1d1d13771 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs @@ -203,10 +203,8 @@ private static Domain.Common.Result BuildParentHabit( bool isBadHabit = JsonArgumentParser.GetOptionalBool(args, "is_bad_habit") ?? false; var dueTime = JsonArgumentParser.ParseTimeOnly(args, "due_time"); - var (reminderTimes, scheduledReminders) = ReminderStoreNormalizer.Normalize( - dueTime, - JsonArgumentParser.ParseIntArray(args, "reminder_times"), - JsonArgumentParser.ParseScheduledReminders(args)); + var reminderTimes = JsonArgumentParser.ParseIntArray(args, "reminder_times"); + var scheduledReminders = JsonArgumentParser.ParseScheduledReminders(args); return Habit.Create(new HabitCreateParams( userId, title, frequencyUnit, frequencyQuantity, diff --git a/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs index 5125a9097..b6b3cf7bc 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/CreateSubHabitTool.cs @@ -82,10 +82,8 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel var (dueTime, dueEndTime) = ParseTimeOptions(args); var (isBadHabit, reminderEnabled, slipAlertEnabled, isFlexible) = ParseBooleanFlags(args); var dueDate = JsonArgumentParser.ParseDateOnly(args, "due_date"); - var (reminderTimes, scheduledReminders) = ReminderStoreNormalizer.Normalize( - dueTime, - JsonArgumentParser.ParseIntArray(args, "reminder_times"), - JsonArgumentParser.ParseScheduledReminders(args)); + var reminderTimes = JsonArgumentParser.ParseIntArray(args, "reminder_times"); + var scheduledReminders = JsonArgumentParser.ParseScheduledReminders(args); string? description = JsonArgumentParser.GetOptionalString(args, "description"); string? emoji = JsonArgumentParser.GetOptionalString(args, "emoji"); diff --git a/src/Orbit.Application/Chat/Tools/Implementations/ReminderStoreNormalizer.cs b/src/Orbit.Application/Chat/Tools/Implementations/ReminderStoreNormalizer.cs index ec4733bd7..20ae7512c 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/ReminderStoreNormalizer.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/ReminderStoreNormalizer.cs @@ -7,38 +7,6 @@ internal static class ReminderStoreNormalizer { private const int MinutesPerDay = 24 * 60; - /// - /// Returns the reminder stores the habit should persist. When the habit has no due time both stores - /// pass through untouched. When it has a due time and the caller is setting reminders, every reminder - /// is expressed as a minute offset before the due time and the scheduled-reminder store is emptied; - /// when the caller is not touching reminders (both inputs null) neither store is modified. - /// - public static (List? ReminderTimes, List? ScheduledReminders) Normalize( - TimeOnly? dueTime, - List? reminderTimes, - List? scheduledReminders) - { - if (dueTime is null) - return (reminderTimes, scheduledReminders); - - if (reminderTimes is null && scheduledReminders is null) - return (null, null); - - var offsets = reminderTimes is not null ? new List(reminderTimes) : new List(); - - if (scheduledReminders is not null) - { - foreach (var reminder in scheduledReminders) - { - var offset = ToMinutesBeforeDueTime(dueTime.Value, reminder); - if (!offsets.Contains(offset)) - offsets.Add(offset); - } - } - - return (offsets.Count > 0 ? offsets : reminderTimes, []); - } - public static (List? ReminderTimes, List? ScheduledReminders) NormalizeForUpdate( TimeOnly? newDueTime, TimeOnly? previousDueTime, @@ -48,10 +16,10 @@ public static (List? ReminderTimes, List? ScheduledR IReadOnlyList existingScheduledReminders) { if (suppliedReminderTimes is not null || suppliedScheduledReminders is not null) - return Normalize(newDueTime, suppliedReminderTimes, suppliedScheduledReminders); + return (suppliedReminderTimes, suppliedScheduledReminders); - if (previousDueTime is null && newDueTime is { } addedDueTime && existingScheduledReminders.Count > 0) - return (ToOffsets(addedDueTime, existingScheduledReminders), []); + if (previousDueTime is null && newDueTime.HasValue && existingScheduledReminders.Count > 0) + return (null, existingScheduledReminders.ToList()); if (newDueTime is null && previousDueTime is { } removedDueTime && existingReminderTimes.Count > 0) return ([], ToScheduledReminders(removedDueTime, existingReminderTimes)); @@ -59,18 +27,6 @@ public static (List? ReminderTimes, List? ScheduledR return (null, null); } - private static List ToOffsets(TimeOnly dueTime, IReadOnlyList reminders) - { - var offsets = new List(); - foreach (var reminder in reminders) - { - var offset = ToMinutesBeforeDueTime(dueTime, reminder); - if (!offsets.Contains(offset)) - offsets.Add(offset); - } - return offsets; - } - private static List ToScheduledReminders(TimeOnly dueTime, IReadOnlyList offsets) { var reminders = new List(); @@ -84,19 +40,6 @@ private static List ToScheduledReminders(TimeOnly dueTime return reminders; } - /// - /// Minutes before the due time a scheduled reminder fires. A same-day reminder timed after the due time - /// yields a negative raw value; it is clamped to 0 (fire at the due time) rather than dropped, so no - /// reminder is silently lost. - /// - private static int ToMinutesBeforeDueTime(TimeOnly dueTime, ScheduledReminderTime reminder) - { - var minutesBefore = (int)(dueTime.ToTimeSpan() - reminder.Time.ToTimeSpan()).TotalMinutes; - if (reminder.When == ScheduledReminderWhen.DayBefore) - minutesBefore += MinutesPerDay; - return Math.Max(minutesBefore, 0); - } - private static ScheduledReminderTime ToScheduledReminder(TimeOnly dueTime, int offsetMinutes) { var minutes = (int)dueTime.ToTimeSpan().TotalMinutes - offsetMinutes; diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index 67fc844f7..fd1a89237 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -32,6 +32,7 @@ public static class AppConstants public const int DefaultOverdueWindowDays = 7; public const int MaxScheduledReminders = DomainConstants.MaxScheduledReminders; public const int MaxReminderTimes = DomainConstants.MaxReminderTimes; + public const int MaxRelativeReminders = DomainConstants.MaxRelativeReminders; public const int MaxReminderMinutesBefore = DomainConstants.MaxReminderMinutesBefore; public const int MaxIntervalWeeks = DomainConstants.MaxIntervalWeeks; public const int MaxPushSubscriptionsPerUser = 5; diff --git a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs index 2ded2c9fb..2e73e4f48 100644 --- a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs @@ -36,7 +36,8 @@ public record BulkHabitItem( string? GoogleEventId = null, string? Emoji = null, IReadOnlyList? Tags = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public record BulkCreateResult(IReadOnlyList Results); @@ -157,6 +158,7 @@ private async Task CreateSingleHabit( IsGeneral: item.IsGeneral, IsFlexible: item.IsFlexible, ScheduledReminders: item.ScheduledReminders, + RelativeReminders: item.RelativeReminders, ChecklistItems: item.ChecklistItems, Position: rootPosition, GoogleEventId: item.GoogleEventId, diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index e219b6b8f..1ad5ea394 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -97,6 +97,7 @@ private async Task> HandleLockedAsync( IsGeneral: request.IsGeneral, IsFlexible: opts.IsFlexible, ScheduledReminders: opts.ScheduledReminders, + RelativeReminders: opts.RelativeReminders, Position: nextPosition, IntervalWeeks: request.IntervalWeeks)); diff --git a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs index 6922b6770..6dc56ada1 100644 --- a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs @@ -102,6 +102,7 @@ public async Task> Handle(CreateSubHabitCommand request, Cancellati IsFlexible: opts.IsFlexible, EndDate: opts.EndDate, ScheduledReminders: opts.ScheduledReminders, + RelativeReminders: opts.RelativeReminders, Position: nextPosition, IntervalWeeks: intervalWeeks)); diff --git a/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs index ae59be046..c73d120bc 100644 --- a/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs @@ -154,6 +154,8 @@ private static Result CloneHabit( IsFlexible: source.IsFlexible, EndDate: source.EndDate, ScheduledReminders: source.ScheduledReminders, + RelativeReminders: source.RelativeReminders, + ReminderTimes: source.ReminderTimes, Position: position, IntervalWeeks: source.IntervalWeeks)); diff --git a/src/Orbit.Application/Habits/Commands/HabitCommandOptions.cs b/src/Orbit.Application/Habits/Commands/HabitCommandOptions.cs index 364bee156..a6198c1a2 100644 --- a/src/Orbit.Application/Habits/Commands/HabitCommandOptions.cs +++ b/src/Orbit.Application/Habits/Commands/HabitCommandOptions.cs @@ -16,7 +16,8 @@ public record HabitCommandOptions( IReadOnlyList? ChecklistItems = null, IReadOnlyList? ScheduledReminders = null, DateOnly? EndDate = null, - bool IsFlexible = false); + bool IsFlexible = false, + IReadOnlyList? RelativeReminders = null); /// /// Optional settings for UpdateHabitCommand. Uses nullable booleans @@ -32,4 +33,5 @@ public record UpdateHabitCommandOptions( IReadOnlyList? ChecklistItems = null, IReadOnlyList? ScheduledReminders = null, DateOnly? EndDate = null, - bool? IsFlexible = null); + bool? IsFlexible = null, + IReadOnlyList? RelativeReminders = null); diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index 34fd2e3da..8c3c6b061 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -100,6 +100,7 @@ private async Task> PrepareUpdateAsync( EndDate: opts.EndDate, ClearEndDate: request.ClearEndDate, ScheduledReminders: opts.ScheduledReminders, + RelativeReminders: opts.RelativeReminders, Emoji: request.Emoji, UserToday: today, IntervalWeeks: request.IntervalWeeks ?? habit.IntervalWeeks); diff --git a/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs b/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs index e60406d4b..53cadafa2 100644 --- a/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs @@ -194,7 +194,8 @@ private static HabitScheduleItem MapToScheduleItem( children, children.Count > 0, flexibleTarget, flexibleCompleted, isLoggedInRange, instances, Emoji: habit.Emoji, - IntervalWeeks: habit.IntervalWeeks); + IntervalWeeks: habit.IntervalWeeks, + RelativeReminders: habit.RelativeReminders); } private static (int? Target, int? Completed) CalculateFlexibleProgress( diff --git a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs index 58a15264c..aa0eb4509 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs @@ -59,7 +59,8 @@ public record HabitDetailResponse( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? LinkedGoals = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - bool? SlipAlertEnabled = null); + bool? SlipAlertEnabled = null, + IReadOnlyList? RelativeReminders = null); public record GetHabitByIdQuery(Guid UserId, Guid HabitId) : IRequest>; @@ -122,7 +123,8 @@ public async Task> Handle(GetHabitByIdQuery request, habit.CreatedAtUtc, children, Emoji: habit.Emoji, - IntervalWeeks: habit.IntervalWeeks)); + IntervalWeeks: habit.IntervalWeeks, + RelativeReminders: habit.RelativeReminders)); } } diff --git a/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs index 9431a94e6..45022730c 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs @@ -78,7 +78,8 @@ public async Task> Handle(GetHabitFullDetailQuer LinkedGoals: habit.ParentHabitId.HasValue ? habit.Goals.Select(goal => new LinkedGoalDto(goal.Id, goal.Title)).ToList() : null, - SlipAlertEnabled: habit.ParentHabitId.HasValue ? habit.SlipAlertEnabled : null); + SlipAlertEnabled: habit.ParentHabitId.HasValue ? habit.SlipAlertEnabled : null, + RelativeReminders: habit.RelativeReminders); var metrics = HabitMetricsCalculator.Calculate( habit, diff --git a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs index 423182231..fb7428b29 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs @@ -48,7 +48,8 @@ public record HabitScheduleItem( IReadOnlyList Instances, IReadOnlyList? SearchMatches = null, string? Emoji = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public record HabitScheduleChildItem( Guid Id, diff --git a/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs index 400673294..78e9db587 100644 --- a/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs +++ b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs @@ -299,7 +299,8 @@ internal static HabitScheduleItem MapToScheduleItem( isLoggedInRange, instances, ComputeSearchMatches(h, ctx), Emoji: h.Emoji, - IntervalWeeks: h.IntervalWeeks); + IntervalWeeks: h.IntervalWeeks, + RelativeReminders: h.RelativeReminders); } private static (int? Target, int? Completed) CalculateFlexibleProgress( diff --git a/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs index 7fdfe8f0e..a0674641e 100644 --- a/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs @@ -33,6 +33,7 @@ public BulkCreateHabitsCommandValidator() .When(h => h.FrequencyUnit is not null); SharedHabitRules.AddIntervalWeeksRules(habit.RuleFor(h => h.IntervalWeeks)); + SharedHabitRules.AddRelativeReminderRules(habit.RuleFor(h => h.RelativeReminders)); habit.RuleFor(h => h.Tags) .Must(tags => tags!.Count <= AppConstants.MaxTagsPerHabit) diff --git a/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs index f541d07c6..d4446dd89 100644 --- a/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/CreateHabitCommandValidator.cs @@ -64,6 +64,7 @@ public CreateHabitCommandValidator() SharedHabitRules.AddScheduledReminderRules(RuleFor(x => x.Options != null ? x.Options.ScheduledReminders : null)); SharedHabitRules.AddReminderTimesRules(RuleFor(x => x.Options != null ? x.Options.ReminderTimes : null)); + SharedHabitRules.AddRelativeReminderRules(RuleFor(x => x.Options != null ? x.Options.RelativeReminders : null)); SharedHabitRules.AddGoalIdsRules(this, x => x.GoalIds); } diff --git a/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs index 74d45b607..06e1dd6bf 100644 --- a/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/CreateSubHabitCommandValidator.cs @@ -40,6 +40,7 @@ public CreateSubHabitCommandValidator() SharedHabitRules.AddScheduledReminderRules(RuleFor(x => x.Options != null ? x.Options.ScheduledReminders : null)); SharedHabitRules.AddReminderTimesRules(RuleFor(x => x.Options != null ? x.Options.ReminderTimes : null)); + SharedHabitRules.AddRelativeReminderRules(RuleFor(x => x.Options != null ? x.Options.RelativeReminders : null)); RuleFor(x => x.TagIds) .Must(tags => tags is null || tags.Count <= AppConstants.MaxTagsPerHabit) diff --git a/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs b/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs index f303816d4..bf70dcc8a 100644 --- a/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs +++ b/src/Orbit.Application/Habits/Validators/SharedHabitRules.cs @@ -130,6 +130,18 @@ public static void AddReminderTimesRules(IRuleBuilder?> .WithMessage("Reminder times must not contain duplicate values"); } + public static void AddRelativeReminderRules(IRuleBuilder?> rule) + { + rule.Must(reminders => reminders is null || + (reminders.Count <= AppConstants.MaxRelativeReminders + && reminders.Distinct().Count() == reminders.Count + && reminders.All(r => (r.MinutesBefore.HasValue != r.When.HasValue) + && (r.When.HasValue == r.Time.HasValue) + && (!r.MinutesBefore.HasValue || r.MinutesBefore.Value is >= -1439 and <= AppConstants.MaxReminderMinutesBefore) + && (!r.When.HasValue || Enum.IsDefined(r.When.Value))))) + .WithMessage("Relative reminders must be unique and have a valid offset or local time"); + } + public static void AddGoalIdsRules( AbstractValidator validator, System.Linq.Expressions.Expression?>> expression) diff --git a/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs b/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs index b7cb94c5e..8eba6d7a0 100644 --- a/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/UpdateHabitCommandValidator.cs @@ -82,6 +82,7 @@ public UpdateHabitCommandValidator(IGenericRepository habitRepository) SharedHabitRules.AddScheduledReminderRules(RuleFor(x => x.Options!.ScheduledReminders)); SharedHabitRules.AddReminderTimesRules(RuleFor(x => x.Options!.ReminderTimes)); + SharedHabitRules.AddRelativeReminderRules(RuleFor(x => x.Options!.RelativeReminders)); }); SharedHabitRules.AddGoalIdsRules(this, x => x.GoalIds); diff --git a/src/Orbit.Domain/Common/DomainConstants.cs b/src/Orbit.Domain/Common/DomainConstants.cs index c12084c65..619bfcd5e 100644 --- a/src/Orbit.Domain/Common/DomainConstants.cs +++ b/src/Orbit.Domain/Common/DomainConstants.cs @@ -4,6 +4,7 @@ public static class DomainConstants { public const int MaxScheduledReminders = 5; public const int MaxReminderTimes = 10; + public const int MaxRelativeReminders = 15; public const int MaxReminderMinutesBefore = 10080; public const int MaxHabitEmojiLength = 32; public const int MaxIntervalWeeks = 52; diff --git a/src/Orbit.Domain/Common/DomainErrors.cs b/src/Orbit.Domain/Common/DomainErrors.cs index 0352fd647..70a97e1c5 100644 --- a/src/Orbit.Domain/Common/DomainErrors.cs +++ b/src/Orbit.Domain/Common/DomainErrors.cs @@ -79,6 +79,7 @@ public static class DomainErrors public static readonly AppError MaxScheduledReminders = new("MAX_SCHEDULED_REMINDERS", "A habit can have at most {0} scheduled reminders."); public static readonly AppError MaxReminderTimes = new("MAX_REMINDER_TIMES", "A habit can have at most {0} reminder times."); public static readonly AppError DuplicateScheduledReminders = new("DUPLICATE_SCHEDULED_REMINDERS", "Scheduled reminders must not contain duplicate entries."); + public static readonly AppError InvalidRelativeReminders = new("INVALID_RELATIVE_REMINDERS", "Relative reminders must be unique and have a valid offset or local time."); public static readonly AppError EmojiTooLong = new("EMOJI_TOO_LONG", "Habit emoji must not exceed {0} characters."); public static readonly AppError TargetValueInvalid = new("TARGET_VALUE_INVALID", "Target value must be greater than 0."); diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index b63a6fadd..f44a6e715 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -30,7 +30,8 @@ public record HabitCreateParams( int? Position = null, string? GoogleEventId = null, string? Emoji = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public record HabitUpdateParams( string Title, @@ -53,7 +54,8 @@ public record HabitUpdateParams( IReadOnlyList? ScheduledReminders = null, string? Emoji = null, DateOnly? UserToday = null, - int? IntervalWeeks = null); + int? IntervalWeeks = null, + IReadOnlyList? RelativeReminders = null); public class Habit : Entity, ITimestamped, ISoftDeletable, IHabitSchedule { @@ -88,6 +90,7 @@ public class Habit : Entity, ITimestamped, ISoftDeletable, IHabitSchedule public bool SlipAlertEnabled { get; private set; } public IReadOnlyList ChecklistItems { get; private set; } = []; public IReadOnlyList ScheduledReminders { get; private set; } = []; + public IReadOnlyList RelativeReminders { get; private set; } = []; public DateOnly? EndDate { get; private set; } public int? Position { get; private set; } public string? GoogleEventId { get; private set; } @@ -143,6 +146,10 @@ public static Result Create(HabitCreateParams p) if (reminderTimesValidation is not null) return Result.Failure(reminderTimesValidation); + var relativeValidation = ValidateCreateRelativeReminders(p); + if (relativeValidation is not null) + return Result.Failure(relativeValidation); + return Result.Success(new Habit { UserId = p.UserId, @@ -165,10 +172,13 @@ public static Result Create(HabitCreateParams p) DueEndTime = p.DueEndTime, ParentHabitId = p.ParentHabitId, ReminderEnabled = p.ReminderEnabled, - ReminderTimes = p.ReminderTimes ?? [15], + ReminderTimes = p.ReminderTimes ?? (p.DueTime.HasValue && p.ScheduledReminders is { Count: > 0 } ? [] : [15]), SlipAlertEnabled = p.SlipAlertEnabled, ChecklistItems = p.ChecklistItems ?? [], - ScheduledReminders = p.ScheduledReminders ?? [], + ScheduledReminders = p.DueTime.HasValue ? [] : p.ScheduledReminders ?? [], + RelativeReminders = p.DueTime.HasValue + ? (p.RelativeReminders ?? []).Concat((p.ScheduledReminders ?? []).Select(RelativeReminderTime.FromScheduled)).Distinct().ToList() + : p.RelativeReminders ?? [], EndDate = p.EndDate, Position = p.Position, GoogleEventId = p.GoogleEventId, @@ -449,13 +459,40 @@ public Result Update(HabitUpdateParams p) if (validation.IsFailure) return validation; + var previousDueTime = DueTime; ApplyRequiredUpdates(p); ApplyOptionalUpdates(p); + if (!previousDueTime.HasValue && DueTime.HasValue && p.ReminderTimes is null && RelativeReminders.Count > 0) + ReminderTimes = []; + + if (!DueTime.HasValue && previousDueTime.HasValue && RelativeReminders.Count > 0) + { + ScheduledReminders = ScheduledReminders.Concat(RelativeReminders.Select(r => + r.When.HasValue + ? new ScheduledReminderTime(r.When.Value, r.Time!.Value) + : OffsetToScheduled(previousDueTime.Value, r.MinutesBefore!.Value))) + .Distinct().ToList(); + RelativeReminders = []; + } + UpdatedAtUtc = DateTime.UtcNow; return Result.Success(); } + private static AppError? ValidateCreateRelativeReminders(HabitCreateParams p) + { + var validation = HabitInvariants.ValidateRelativeReminders(p.RelativeReminders); + if (validation is not null) + return validation; + + if (p.DueTime.HasValue) + return (p.RelativeReminders?.Count ?? 0) + (p.ScheduledReminders?.Count ?? 0) <= DomainConstants.MaxRelativeReminders + ? null : DomainErrors.InvalidRelativeReminders; + + return p.RelativeReminders is { Count: > 0 } ? DomainErrors.InvalidRelativeReminders : null; + } + public Result ValidateUpdate(HabitUpdateParams p) { if (string.IsNullOrWhiteSpace(p.Title)) @@ -494,6 +531,13 @@ public Result ValidateUpdate(HabitUpdateParams p) if (scheduledReminderValidation is not null) return scheduledReminderValidation; + var relativeValidation = HabitInvariants.ValidateRelativeReminders(p.RelativeReminders); + if (relativeValidation is not null) + return relativeValidation; + + if (p.DueTime.HasValue && (p.RelativeReminders?.Count ?? RelativeReminders.Count) + (p.ScheduledReminders?.Count ?? ScheduledReminders.Count) > DomainConstants.MaxRelativeReminders) + return DomainErrors.InvalidRelativeReminders; + return HabitInvariants.ValidateReminderTimes(p.ReminderTimes); } @@ -550,6 +594,14 @@ private void ApplyOptionalUpdates(HabitUpdateParams p) ChecklistItems = p.ChecklistItems; if (p.ScheduledReminders is not null) ScheduledReminders = p.ScheduledReminders; + if (p.RelativeReminders is not null) + RelativeReminders = p.RelativeReminders; + + if (DueTime.HasValue && ScheduledReminders.Count > 0) + { + RelativeReminders = RelativeReminders.Concat(ScheduledReminders.Select(RelativeReminderTime.FromScheduled)).Distinct().ToList(); + ScheduledReminders = []; + } if (p.ClearEndDate == true || IsGeneral) EndDate = null; @@ -560,6 +612,14 @@ private void ApplyOptionalUpdates(HabitUpdateParams p) RecomputeCompletionForEndDate(); } + private static ScheduledReminderTime OffsetToScheduled(TimeOnly dueTime, int minutesBefore) + { + var minutes = (int)dueTime.ToTimeSpan().TotalMinutes - minutesBefore; + var when = minutes >= 0 ? ScheduledReminderWhen.SameDay : ScheduledReminderWhen.DayBefore; + var localMinutes = ((minutes % 1440) + 1440) % 1440; + return new ScheduledReminderTime(when, TimeOnly.FromTimeSpan(TimeSpan.FromMinutes(localMinutes))); + } + private void RecomputeCompletionForEndDate() { if (EndDate.HasValue && DueDate > EndDate.Value) diff --git a/src/Orbit.Domain/Entities/HabitInvariants.cs b/src/Orbit.Domain/Entities/HabitInvariants.cs index 32a85bd5b..18f36e6fd 100644 --- a/src/Orbit.Domain/Entities/HabitInvariants.cs +++ b/src/Orbit.Domain/Entities/HabitInvariants.cs @@ -92,6 +92,23 @@ internal static class HabitInvariants return null; } + public static AppError? ValidateRelativeReminders(IReadOnlyList? reminders) + { + if (reminders is null) + return null; + + if (reminders.Count > DomainConstants.MaxRelativeReminders || reminders.Count != reminders.Distinct().Count()) + return DomainErrors.InvalidRelativeReminders; + + if (reminders.Any(r => (r.MinutesBefore.HasValue == r.When.HasValue) + || (r.When.HasValue != r.Time.HasValue) + || (r.MinutesBefore.HasValue && r.MinutesBefore.Value is < -1439 or > DomainConstants.MaxReminderMinutesBefore) + || (r.When.HasValue && !Enum.IsDefined(r.When.Value)))) + return DomainErrors.InvalidRelativeReminders; + + return null; + } + public static AppError? ValidateEmoji(string? emoji) { if (emoji is null) diff --git a/src/Orbit.Domain/ValueObjects/RelativeReminderTime.cs b/src/Orbit.Domain/ValueObjects/RelativeReminderTime.cs new file mode 100644 index 000000000..0e677ed22 --- /dev/null +++ b/src/Orbit.Domain/ValueObjects/RelativeReminderTime.cs @@ -0,0 +1,12 @@ +using Orbit.Domain.Enums; + +namespace Orbit.Domain.ValueObjects; + +public record RelativeReminderTime( + int? MinutesBefore = null, + ScheduledReminderWhen? When = null, + TimeOnly? Time = null) +{ + public static RelativeReminderTime FromScheduled(ScheduledReminderTime reminder) => + new(When: reminder.When, Time: reminder.Time); +} diff --git a/src/Orbit.Infrastructure/Migrations/20260926170829_FoldDueTimeScheduledReminders.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260926170829_FoldDueTimeScheduledReminders.Designer.cs new file mode 100644 index 000000000..85d6e3389 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260926170829_FoldDueTimeScheduledReminders.Designer.cs @@ -0,0 +1,2705 @@ +// +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("20260926170829_FoldDueTimeScheduledReminders")] + partial class FoldDueTimeScheduledReminders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .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.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "Model", "Purpose", "UserId") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Date", "Model", "Purpose", "UserId"), false); + + 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 = "1.3.11" + }, + new + { + Key = "RequireApiKeyCreationStepUp", + Description = "Turn on once a client build carrying the API key creation challenge flow is live in the Play fleet", + Value = "false" + }); + }); + + 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 = "Pro", + 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, + 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.ClosedMonthRecap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DateFrom") + .HasColumnType("date"); + + b.Property("DateTo") + .HasColumnType("date"); + + b.Property("ResponseJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "DateFrom", "DateTo") + .IsUnique(); + + b.ToTable("ClosedMonthRecaps"); + }); + + 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("FirstCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + 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("IntervalWeeks") + .HasColumnType("integer"); + + 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("RelativeReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + 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("IsSlip") + .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("DedupeKey") + .HasColumnType("text"); + + 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("DedupeKey") + .IsUnique() + .HasFilter("\"DedupeKey\" IS NOT NULL"); + + 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") + .IsConcurrencyToken() + .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("RequestOrdinal") + .HasColumnType("integer"); + + 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", "RequestOrdinal") + .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.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Origin") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + 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("AiMessagesLocalDate") + .HasColumnType("date"); + + b.Property("AiMessagesUsedToday") + .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("LastPurgedCompletionDate") + .HasColumnType("date"); + + 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("PreGapFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("PreGapLastActiveDate") + .HasColumnType("date"); + + 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("StripeSubscriptionEventCreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionEndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionLapseReason") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + 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.UserSessionRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserSessionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserSessionId"); + + b.ToTable("UserSessionRefreshTokens"); + }); + + 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.ClosedMonthRecap", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .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.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.UserSessionRefreshToken", b => + { + b.HasOne("Orbit.Domain.Entities.UserSession", null) + .WithMany() + .HasForeignKey("UserSessionId") + .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/20260926170829_FoldDueTimeScheduledReminders.cs b/src/Orbit.Infrastructure/Migrations/20260926170829_FoldDueTimeScheduledReminders.cs new file mode 100644 index 000000000..ff951e0c6 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260926170829_FoldDueTimeScheduledReminders.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class FoldDueTimeScheduledReminders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RelativeReminders", + table: "Habits", + type: "jsonb", + nullable: false, + defaultValueSql: "'[]'::jsonb"); + + migrationBuilder.Sql(""" + UPDATE "Habits" + SET "RelativeReminders" = "ScheduledReminders", + "ScheduledReminders" = '[]'::jsonb + WHERE "DueTime" IS NOT NULL + AND jsonb_array_length("ScheduledReminders") > 0; + + CREATE OR REPLACE FUNCTION fold_due_time_scheduled_reminders() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW."DueTime" IS NOT NULL + AND jsonb_array_length(NEW."ScheduledReminders") > 0 THEN + SELECT COALESCE(jsonb_agg(value), '[]'::jsonb) + INTO NEW."RelativeReminders" + FROM ( + SELECT DISTINCT value + FROM jsonb_array_elements( + NEW."RelativeReminders" || NEW."ScheduledReminders") AS entries(value) + ) AS distinct_reminders; + NEW."ScheduledReminders" := '[]'::jsonb; + END IF; + RETURN NEW; + END; + $$; + + CREATE TRIGGER fold_due_time_scheduled_reminders_on_write + BEFORE INSERT OR UPDATE ON "Habits" + FOR EACH ROW EXECUTE FUNCTION fold_due_time_scheduled_reminders(); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + throw new NotSupportedException("Reverting relative reminders would discard user reminders."); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index dc2bf2def..6a445117c 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1260,6 +1260,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Position") .HasColumnType("integer"); + b.Property("RelativeReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + b.Property("ReminderEnabled") .HasColumnType("boolean"); diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index e643c3a20..268851193 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -807,6 +807,14 @@ private static void ConfigureHabitEntity( .HasDefaultValueSql(EmptyJsonArraySql) .Metadata.SetValueComparer(CreateReadOnlyListComparer()); + entity.Property(h => h.RelativeReminders) + .HasConversion( + v => SerializeJson(v), + v => DeserializeJson(v, new List())) + .HasColumnType(JsonbColumnType) + .HasDefaultValueSql(EmptyJsonArraySql) + .Metadata.SetValueComparer(CreateReadOnlyListComparer()); + if (encConverter is null || nullableEncConverter is null) return; diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index a6b533fb5..75a0dfd15 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -32,7 +32,8 @@ public partial class ReminderSchedulerService( FrequencyUnit = h.FrequencyUnit, FrequencyQuantity = h.FrequencyQuantity, IntervalWeeks = h.IntervalWeeks, IsFlexible = h.IsFlexible, Days = h.Days, DueTime = h.DueTime, ReminderTimes = h.ReminderTimes, - ScheduledReminders = h.ScheduledReminders + ScheduledReminders = h.ScheduledReminders, + RelativeReminders = h.RelativeReminders }; private static readonly Expression> UserProjection = u => new SchedulerUser @@ -127,10 +128,20 @@ private async Task ProcessRelativeReminders(OrbitDbContext dbContext, List (r.HabitId, r.Date, r.MinutesBefore)) .ToHashSet(); + var sentClockKeys = await dbContext.SentReminders + .AsNoTracking() + .Where(r => habitIds.Contains(r.HabitId) && r.ReminderTimeUtc != null + && r.Date >= minWindowDate && r.Date <= maxWindowDate) + .Select(r => new { r.HabitId, r.Date, r.ReminderTimeUtc, r.When }) + .ToListAsync(ct); + var sentClockSet = sentClockKeys + .Select(r => (r.HabitId, r.Date, Time: r.ReminderTimeUtc!.Value, r.When)) + .ToHashSet(); + foreach (var habit in habits) { await ProcessSingleRelativeReminderAsync( - habit, users, loggedHabitDates, sentReminderSet, pending, dbContext, nowUtc, ct); + habit, users, loggedHabitDates, sentReminderSet, sentClockSet, pending, dbContext, nowUtc, ct); } } @@ -148,10 +159,19 @@ private static DateTime LocalDueInstantUtc(DateTime dueLocal, TimeZoneInfo tz) return TimeZoneInfo.ConvertTimeToUtc(dueLocal, tz); } + private static DateTime LocalReminderInstantUtc(DateTime reminderLocal, TimeZoneInfo tz) + { + while (tz.IsInvalidTime(reminderLocal)) + reminderLocal = reminderLocal.AddMinutes(1); + + return LocalDueInstantUtc(reminderLocal, tz); + } + private async Task ProcessSingleRelativeReminderAsync( SchedulerHabit habit, Dictionary users, HashSet<(Guid HabitId, DateOnly Date)> loggedHabitDates, HashSet<(Guid HabitId, DateOnly Date, int MinutesBefore)> sentReminderSet, + HashSet<(Guid HabitId, DateOnly Date, TimeOnly Time, ScheduledReminderWhen? When)> sentClockSet, List pending, OrbitDbContext dbContext, DateTime nowUtc, CancellationToken ct) { if (!users.TryGetValue(habit.UserId, out var user)) return; @@ -160,14 +180,15 @@ private async Task ProcessSingleRelativeReminderAsync( var userNow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz); var userToday = DateOnly.FromDateTime(userNow); - for (var dayOffset = 0; dayOffset <= MaxRelativeLookaheadDays; dayOffset++) + for (var dayOffset = -1; dayOffset <= MaxRelativeLookaheadDays; dayOffset++) { var occurrenceDate = userToday.AddDays(dayOffset); if (!HabitScheduleService.IsHabitDueOnDate(habit, occurrenceDate, user.WeekStartDay)) continue; if (loggedHabitDates.Contains((habit.Id, occurrenceDate))) continue; var dueUtc = LocalDueInstantUtc(occurrenceDate.ToDateTime(habit.DueTime!.Value), tz); - foreach (var minutesBefore in habit.ReminderTimes) + foreach (var minutesBefore in habit.ReminderTimes.Concat( + habit.RelativeReminders.Where(r => r.MinutesBefore.HasValue).Select(r => r.MinutesBefore!.Value)).Distinct()) { var reminderUtc = dueUtc.AddMinutes(-minutesBefore); var reminderLocalDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(reminderUtc, tz)); @@ -175,7 +196,9 @@ private async Task ProcessSingleRelativeReminderAsync( if (sentReminderSet.Contains((habit.Id, occurrenceDate, minutesBefore))) continue; var lang = user.Language ?? "en"; - var minutesText = FormatReminderText(minutesBefore, lang); + var minutesText = minutesBefore < 0 + ? FormatScheduledReminderText(ScheduledReminderWhen.SameDay, lang) + : FormatReminderText(minutesBefore, lang); var sentReminder = SentReminder.Create(habit.Id, occurrenceDate, minutesBefore); var notification = Notification.Create( @@ -191,6 +214,31 @@ private async Task ProcessSingleRelativeReminderAsync( if (logger.IsEnabled(LogLevel.Debug)) LogSentReminder(logger, minutesBefore, habit.Id, habit.UserId); } + + foreach (var reminder in habit.RelativeReminders.Where(r => r.When.HasValue) + .Concat(habit.ScheduledReminders.Select(Domain.ValueObjects.RelativeReminderTime.FromScheduled)).Distinct()) + { + var sendDate = reminder.When == ScheduledReminderWhen.DayBefore + ? occurrenceDate.AddDays(-1) : occurrenceDate; + if (sendDate != userToday) continue; + + var sendUtc = LocalReminderInstantUtc(sendDate.ToDateTime(reminder.Time!.Value), tz); + if (nowUtc < sendUtc) continue; + + var clockKey = (habit.Id, sendDate, reminder.Time.Value, reminder.When); + if (sentClockSet.Contains(clockKey)) continue; + + var lang = user.Language ?? "en"; + var body = FormatScheduledReminderText(reminder.When!.Value, lang); + var sentReminder = SentReminder.Create(habit.Id, sendDate, 0, reminder.Time, reminder.When); + var notification = Notification.Create(habit.UserId, habit.Title, body, NotificationUrls.Home, habit.Id); + sentClockSet.Add(clockKey); + + if (!await TryRecordReminderAsync(habit, sentReminder, notification, dbContext, ct)) + continue; + + pending.Add(new PendingReminderPush(habit.UserId, habit.Title, body, habit.Id)); + } } } diff --git a/src/Orbit.Infrastructure/Services/SchedulerProjections.cs b/src/Orbit.Infrastructure/Services/SchedulerProjections.cs index b1a320048..ce7c374fa 100644 --- a/src/Orbit.Infrastructure/Services/SchedulerProjections.cs +++ b/src/Orbit.Infrastructure/Services/SchedulerProjections.cs @@ -20,6 +20,7 @@ internal sealed class SchedulerHabit : IHabitSchedule public TimeOnly? DueTime { get; set; } public IReadOnlyList ReminderTimes { get; set; } = []; public IReadOnlyList ScheduledReminders { get; set; } = []; + public IReadOnlyList RelativeReminders { get; set; } = []; } internal sealed class SchedulerUser diff --git a/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs index 21c167f8c..27a96927f 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs @@ -402,7 +402,7 @@ public async Task UpdateScheduledReminders_ReplacesReminders() } [Fact] - public async Task UpdateDueTimedHabit_WithScheduledReminders_ConvertsToOffsetsAndClearsScheduledStore() + public async Task UpdateDueTimedHabit_WithScheduledReminders_PreservesClockTimeInRelativeStore() { var habit = CreateHabitWithTime("Standup", FrequencyUnit.Day, 1, new TimeOnly(9, 0)); SetupHabitFound(habit); @@ -418,12 +418,13 @@ public async Task UpdateDueTimedHabit_WithScheduledReminders_ConvertsToOffsetsAn """); result.Success.Should().BeTrue(); - habit.ReminderTimes.Should().Contain(30); + habit.RelativeReminders.Should().ContainSingle(r => + r.When == ScheduledReminderWhen.SameDay && r.Time == new TimeOnly(8, 30)); habit.ScheduledReminders.Should().BeEmpty(); } [Fact] - public async Task UpdateDueTimedHabit_WithDayBeforeScheduledReminder_ConvertsToCrossDayOffset() + public async Task UpdateDueTimedHabit_WithDayBeforeScheduledReminder_PreservesClockTime() { var habit = CreateHabitWithTime("Standup", FrequencyUnit.Day, 1, new TimeOnly(9, 0)); SetupHabitFound(habit); @@ -439,7 +440,8 @@ public async Task UpdateDueTimedHabit_WithDayBeforeScheduledReminder_ConvertsToC """); result.Success.Should().BeTrue(); - habit.ReminderTimes.Should().Contain(720); + habit.RelativeReminders.Should().ContainSingle(r => + r.When == ScheduledReminderWhen.DayBefore && r.Time == new TimeOnly(21, 0)); habit.ScheduledReminders.Should().BeEmpty(); } @@ -465,7 +467,7 @@ public async Task UpdateDueTimedHabit_WithReminderTimes_ClearsStaleScheduledRemi } [Fact] - public async Task UpdateNoDueTimeHabit_AddDueTime_ConvertsScheduledRemindersToOffsets() + public async Task UpdateNoDueTimeHabit_AddDueTime_FoldsScheduledReminders() { var habit = CreateHabitWithScheduledReminders( "Appointment", @@ -476,7 +478,9 @@ public async Task UpdateNoDueTimeHabit_AddDueTime_ConvertsScheduledRemindersToOf result.Success.Should().BeTrue(); habit.DueTime.Should().Be(new TimeOnly(9, 0)); - habit.ReminderTimes.Should().Contain(60); + habit.ReminderTimes.Should().BeEmpty(); + habit.RelativeReminders.Should().ContainSingle(r => + r.When == ScheduledReminderWhen.SameDay && r.Time == new TimeOnly(8, 0)); habit.ScheduledReminders.Should().BeEmpty(); } @@ -496,7 +500,7 @@ public async Task UpdateDueTimedHabit_ClearDueTime_ConvertsReminderTimesToSchedu } [Fact] - public async Task UpdateDueTimedHabit_SameDayReminderAfterDueTime_ClampsOffsetToZeroNotDropped() + public async Task UpdateDueTimedHabit_SameDayReminderAfterDueTime_PreservesLocalTime() { var habit = CreateHabitWithTime("Standup", FrequencyUnit.Day, 1, new TimeOnly(9, 0)); SetupHabitFound(habit); @@ -512,8 +516,10 @@ public async Task UpdateDueTimedHabit_SameDayReminderAfterDueTime_ClampsOffsetTo """); result.Success.Should().BeTrue(); - habit.ReminderTimes.Should().Contain(0); + habit.ReminderTimes.Should().NotContain(0); habit.ScheduledReminders.Should().BeEmpty(); + habit.RelativeReminders.Should().ContainSingle(reminder => + reminder.When == ScheduledReminderWhen.SameDay && reminder.Time == new TimeOnly(10, 0)); } [Fact] diff --git a/tests/Orbit.Application.Tests/Commands/Habits/BulkUpdateHabitsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/BulkUpdateHabitsCommandHandlerTests.cs index 8cbfa90ae..655ba9560 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/BulkUpdateHabitsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/BulkUpdateHabitsCommandHandlerTests.cs @@ -229,7 +229,7 @@ await _unitOfWork.Received(1).AcquireAdvisoryLockAsync( } [Fact] - public async Task Handle_AddingDueTime_MigratesScheduledRemindersToOffsets() + public async Task Handle_AddingDueTime_MigratesScheduledRemindersToRelativeClock() { var habit = Habit.Create(new HabitCreateParams( UserId, @@ -251,12 +251,13 @@ public async Task Handle_AddingDueTime_MigratesScheduledRemindersToOffsets() result.IsSuccess.Should().BeTrue(); habit.DueTime.Should().Be(new TimeOnly(9, 0)); - habit.ReminderTimes.Should().ContainSingle().Which.Should().Be(60); + habit.RelativeReminders.Should().ContainSingle(r => + r.When == ScheduledReminderWhen.SameDay && r.Time == new TimeOnly(8, 0)); habit.ScheduledReminders.Should().BeEmpty(); } [Fact] - public async Task Handle_TimedHabitGivenScheduledReminders_NormalizesToOffsets() + public async Task Handle_TimedHabitGivenScheduledReminders_NormalizesToRelativeClock() { var habit = Habit.Create(new HabitCreateParams( UserId, @@ -279,7 +280,8 @@ public async Task Handle_TimedHabitGivenScheduledReminders_NormalizesToOffsets() var result = await _handler.Handle(command, CancellationToken.None); result.IsSuccess.Should().BeTrue(); - habit.ReminderTimes.Should().ContainSingle().Which.Should().Be(30); + habit.RelativeReminders.Should().ContainSingle(r => + r.When == ScheduledReminderWhen.SameDay && r.Time == new TimeOnly(8, 30)); habit.ScheduledReminders.Should().BeEmpty(); } diff --git a/tests/Orbit.Application.Tests/Validators/CreateHabitCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/CreateHabitCommandValidatorTests.cs index 08a3b54db..0e1434dad 100644 --- a/tests/Orbit.Application.Tests/Validators/CreateHabitCommandValidatorTests.cs +++ b/tests/Orbit.Application.Tests/Validators/CreateHabitCommandValidatorTests.cs @@ -268,6 +268,37 @@ public void Validate_ValidScheduledReminders_NoError() result.ShouldNotHaveValidationErrorFor(x => x.Options != null ? x.Options.ScheduledReminders : null); } + [Fact] + public void Validate_RelativeReminders_AcceptsSignedAndClockForms() + { + var command = ValidCommand() with + { + Options = new HabitCommandOptions( + DueTime: new TimeOnly(9, 0), + RelativeReminders: + [ + new(MinutesBefore: -60), + new(When: DayBefore, Time: new TimeOnly(20, 0)) + ]) + }; + + _validator.TestValidate(command).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_RelativeReminders_RejectsIncompleteForm() + { + var command = ValidCommand() with + { + Options = new HabitCommandOptions( + DueTime: new TimeOnly(9, 0), + RelativeReminders: [new(When: DayBefore)]) + }; + + _validator.TestValidate(command) + .ShouldHaveValidationErrorFor(x => x.Options != null ? x.Options.RelativeReminders : null); + } + [Fact] public void Validate_ScheduledReminders_Over5_HasError() { diff --git a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs index a040f90d4..366352db1 100644 --- a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs @@ -1074,6 +1074,60 @@ public void Update_ReminderTimes_OverLimit_ReturnsFailure() result.Error.Should().Contain($"at most {DomainConstants.MaxReminderTimes} reminder times"); } + [Fact] + public void Create_DueTimeWithScheduledReminders_FoldsEveryClockReminder() + { + var result = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), + ReminderTimes: [30], + ScheduledReminders: + [ + new(ScheduledReminderWhen.SameDay, new TimeOnly(10, 0)), + new(ScheduledReminderWhen.DayBefore, new TimeOnly(20, 0)) + ])); + + result.IsSuccess.Should().BeTrue(); + result.Value.ScheduledReminders.Should().BeEmpty(); + result.Value.ReminderTimes.Should().BeEquivalentTo([30]); + result.Value.RelativeReminders.Should().BeEquivalentTo( + [ + new RelativeReminderTime(When: ScheduledReminderWhen.SameDay, Time: new TimeOnly(10, 0)), + new RelativeReminderTime(When: ScheduledReminderWhen.DayBefore, Time: new TimeOnly(20, 0)) + ]); + } + + [Fact] + public void Create_InvalidSignedOffset_ReturnsFailure() + { + var result = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), + RelativeReminders: [new RelativeReminderTime(MinutesBefore: -1440)])); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("INVALID_RELATIVE_REMINDERS"); + } + + [Fact] + public void Update_MixedHabitWithNoReminderFields_FoldsExistingScheduledReminders() + { + var habit = CreateValidHabit(); + var first = habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + ScheduledReminders: [new(ScheduledReminderWhen.DayBefore, new TimeOnly(20, 0))])); + first.IsSuccess.Should().BeTrue(); + + var second = habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + DueTime: new TimeOnly(9, 0))); + + second.IsSuccess.Should().BeTrue(); + habit.ScheduledReminders.Should().BeEmpty(); + habit.RelativeReminders.Should().ContainSingle(r => + r.When == ScheduledReminderWhen.DayBefore && r.Time == new TimeOnly(20, 0)); + } + [Fact] public void Update_AllOptionalFields_Applied() { diff --git a/tests/Orbit.Domain.Tests/ValueObjects/RelativeReminderTimeTests.cs b/tests/Orbit.Domain.Tests/ValueObjects/RelativeReminderTimeTests.cs new file mode 100644 index 000000000..8b99ba318 --- /dev/null +++ b/tests/Orbit.Domain.Tests/ValueObjects/RelativeReminderTimeTests.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using FluentAssertions; +using Orbit.Domain.Enums; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Domain.Tests.ValueObjects; + +public class RelativeReminderTimeTests +{ + [Fact] + public void ScheduledReminderJson_DeserializesAsRelativeClockReminder() + { + var scheduled = new ScheduledReminderTime(ScheduledReminderWhen.DayBefore, new TimeOnly(20, 0)); + var storedJson = JsonSerializer.Serialize(scheduled); + + var relative = JsonSerializer.Deserialize(storedJson); + + relative.Should().Be(new RelativeReminderTime( + When: ScheduledReminderWhen.DayBefore, + Time: new TimeOnly(20, 0))); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs index 1cb7e5fab..a6d916054 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs @@ -493,6 +493,82 @@ await pushService.Received(1).SendToUserAsync( user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); } + [Theory] + [InlineData("2027-03-14T01:00:00Z", "America/New_York", "2027-03-14", 9, 0, ScheduledReminderWhen.DayBefore, 20, 0)] + [InlineData("2027-03-14T07:00:00Z", "America/New_York", "2027-03-14", 9, 0, ScheduledReminderWhen.SameDay, 2, 30)] + [InlineData("2027-09-26T10:00:00Z", "UTC", "2027-09-26", 9, 0, ScheduledReminderWhen.SameDay, 10, 0)] + public async Task CheckAndSendReminders_FoldedClockReminder_FiresAtItsLocalTime( + string instant, string timeZoneId, string dueDateText, int dueHour, int dueMinute, + ScheduledReminderWhen when, int reminderHour, int reminderMinute) + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + var clock = new MutableTimeProvider(DateTimeOffset.Parse(instant).AddMinutes(-1)); + var user = User.Create("Alex", "alex@test.com").Value; + user.SetTimeZone(timeZoneId).IsSuccess.Should().BeTrue(); + var habit = Habit.Create(new HabitCreateParams( + user.Id, "Workout", FrequencyUnit.Day, 1, + DueDate: DateOnly.Parse(dueDateText), + DueTime: new TimeOnly(dueHour, dueMinute), + ReminderEnabled: true, + ReminderTimes: [], + ScheduledReminders: [new ScheduledReminderTime(when, new TimeOnly(reminderHour, reminderMinute))])).Value; + + habit.ScheduledReminders.Should().BeEmpty(); + habit.RelativeReminders.Should().ContainSingle(); + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService, clock); + await service.CheckAndSendReminders(CancellationToken.None); + (await dbContext.SentReminders.CountAsync(r => r.HabitId == habit.Id)).Should().Be(0); + + clock.SetUtcNow(DateTimeOffset.Parse(instant)); + await service.CheckAndSendReminders(CancellationToken.None); + await service.CheckAndSendReminders(CancellationToken.None); + + var sent = await dbContext.SentReminders.Where(r => r.HabitId == habit.Id).ToListAsync(); + sent.Should().ContainSingle(); + sent[0].When.Should().Be(when); + sent[0].ReminderTimeUtc.Should().Be(new TimeOnly(reminderHour, reminderMinute)); + await pushService.Received(1).SendToUserAsync( + user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); + } + + [Fact] + public async Task CheckAndSendReminders_SignedOffsetAfterDueTime_FiresOnce() + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + var clock = new MutableTimeProvider(new DateTimeOffset(2027, 9, 26, 9, 59, 0, TimeSpan.Zero)); + var user = User.Create("Alex", "alex@test.com").Value; + var habit = Habit.Create(new HabitCreateParams( + user.Id, "Workout", FrequencyUnit.Day, 1, + DueDate: new DateOnly(2027, 9, 26), + DueTime: new TimeOnly(9, 0), + ReminderEnabled: true, + ReminderTimes: [], + RelativeReminders: [new RelativeReminderTime(MinutesBefore: -60)])).Value; + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService, clock); + await service.CheckAndSendReminders(CancellationToken.None); + (await dbContext.SentReminders.CountAsync(r => r.HabitId == habit.Id)).Should().Be(0); + + clock.SetUtcNow(new DateTimeOffset(2027, 9, 26, 10, 0, 0, TimeSpan.Zero)); + await service.CheckAndSendReminders(CancellationToken.None); + await service.CheckAndSendReminders(CancellationToken.None); + + var sent = await dbContext.SentReminders.Where(r => r.HabitId == habit.Id).ToListAsync(); + sent.Should().ContainSingle(); + sent[0].MinutesBefore.Should().Be(-60); + await pushService.Received(1).SendToUserAsync( + user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); + } + [Fact] public async Task CheckAndSendReminders_RelativeReminderAfterItsLocalDay_DoesNotSendLate() { From cd24fe21db1d415b7f7fcbda71638a1e6c3554b3 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 26 Sep 2026 14:16:03 -0300 Subject: [PATCH 2/6] Align habit tool tests with clock reminder storage --- .../Chat/Tools/CreateHabitToolTests.cs | 8 ++++---- .../Chat/Tools/CreateSubHabitToolTests.cs | 13 +++++-------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs index add41f3b8..01ad75dcc 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/CreateHabitToolTests.cs @@ -472,7 +472,7 @@ public async Task WithScheduledReminders_CreatesHabitWithScheduledReminders() } [Fact] - public async Task WithDueTimeAndScheduledReminders_PersistsOffsetsAndEmptiesScheduledStore() + public async Task WithDueTimeAndScheduledReminders_PreservesClockTimeInRelativeStore() { var result = await Execute(""" { @@ -489,13 +489,13 @@ public async Task WithDueTimeAndScheduledReminders_PersistsOffsetsAndEmptiesSche await _habitRepo.Received(1).AddAsync( Arg.Is(habit => habit.Title == "Standup" - && habit.ReminderTimes.Contains(30) + && habit.RelativeReminders.Any(r => r.When == ScheduledReminderWhen.SameDay && r.Time == new TimeOnly(7, 30)) && habit.ScheduledReminders.Count == 0), Arg.Any()); } [Fact] - public async Task WithDueTimeAndDayBeforeScheduledReminder_ConvertsToCrossDayOffset() + public async Task WithDueTimeAndDayBeforeScheduledReminder_PreservesClockTime() { var result = await Execute(""" { @@ -512,7 +512,7 @@ public async Task WithDueTimeAndDayBeforeScheduledReminder_ConvertsToCrossDayOff await _habitRepo.Received(1).AddAsync( Arg.Is(habit => habit.Title == "Standup" - && habit.ReminderTimes.Contains(720) + && habit.RelativeReminders.Any(r => r.When == ScheduledReminderWhen.DayBefore && r.Time == new TimeOnly(20, 0)) && habit.ScheduledReminders.Count == 0), Arg.Any()); } diff --git a/tests/Orbit.Application.Tests/Chat/Tools/CreateSubHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/CreateSubHabitToolTests.cs index 25f1af1b1..d3db6b61a 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/CreateSubHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/CreateSubHabitToolTests.cs @@ -4,6 +4,7 @@ using NSubstitute; using Orbit.Application.Chat.Tools; using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Domain.Enums; using Orbit.Application.Habits.Commands; using Orbit.Domain.Common; @@ -165,7 +166,7 @@ public async Task InvalidParentGuid_ReturnsError() } [Fact] - public async Task DueTimedSubHabit_WithScheduledReminders_ConvertsToOffsetsAndClearsScheduledStore() + public async Task DueTimedSubHabit_WithScheduledReminders_PassesClockTimeToDomain() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(Guid.NewGuid())); @@ -186,15 +187,13 @@ public async Task DueTimedSubHabit_WithScheduledReminders_ConvertsToOffsetsAndCl await _mediator.Received(1).Send( Arg.Is(cmd => cmd.Options != null && - cmd.Options.ReminderTimes != null && - cmd.Options.ReminderTimes.Contains(30) && cmd.Options.ScheduledReminders != null && - cmd.Options.ScheduledReminders.Count == 0), + cmd.Options.ScheduledReminders.Any(r => r.When == ScheduledReminderWhen.SameDay && r.Time == new TimeOnly(7, 30))), Arg.Any()); } [Fact] - public async Task DueTimedSubHabit_WithDayBeforeScheduledReminder_ConvertsToCrossDayOffset() + public async Task DueTimedSubHabit_WithDayBeforeScheduledReminder_PassesClockTimeToDomain() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(Guid.NewGuid())); @@ -215,10 +214,8 @@ public async Task DueTimedSubHabit_WithDayBeforeScheduledReminder_ConvertsToCros await _mediator.Received(1).Send( Arg.Is(cmd => cmd.Options != null && - cmd.Options.ReminderTimes != null && - cmd.Options.ReminderTimes.Contains(720) && cmd.Options.ScheduledReminders != null && - cmd.Options.ScheduledReminders.Count == 0), + cmd.Options.ScheduledReminders.Any(r => r.When == ScheduledReminderWhen.DayBefore && r.Time == new TimeOnly(20, 0))), Arg.Any()); } From 775064e9c904d8775233c9b186f922e17beaa543 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 26 Sep 2026 14:18:18 -0300 Subject: [PATCH 3/6] Guard relative reminders without due times and legacy sends --- src/Orbit.Domain/Entities/Habit.cs | 3 +++ .../Services/ReminderSchedulerService.cs | 3 ++- tests/Orbit.Domain.Tests/Entities/HabitTests.cs | 13 +++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index f44a6e715..421495a55 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -535,6 +535,9 @@ public Result ValidateUpdate(HabitUpdateParams p) if (relativeValidation is not null) return relativeValidation; + if (!p.DueTime.HasValue && !DueTime.HasValue && p.RelativeReminders is { Count: > 0 }) + return DomainErrors.InvalidRelativeReminders; + if (p.DueTime.HasValue && (p.RelativeReminders?.Count ?? RelativeReminders.Count) + (p.ScheduledReminders?.Count ?? ScheduledReminders.Count) > DomainConstants.MaxRelativeReminders) return DomainErrors.InvalidRelativeReminders; diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index 75a0dfd15..8f24349e0 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -226,7 +226,8 @@ private async Task ProcessSingleRelativeReminderAsync( if (nowUtc < sendUtc) continue; var clockKey = (habit.Id, sendDate, reminder.Time.Value, reminder.When); - if (sentClockSet.Contains(clockKey)) continue; + if (sentClockSet.Contains(clockKey) + || sentClockSet.Contains((habit.Id, sendDate, reminder.Time.Value, null))) continue; var lang = user.Language ?? "en"; var body = FormatScheduledReminderText(reminder.When!.Value, lang); diff --git a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs index 366352db1..1865a8c83 100644 --- a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs @@ -1109,6 +1109,19 @@ public void Create_InvalidSignedOffset_ReturnsFailure() result.ErrorCode.Should().Be("INVALID_RELATIVE_REMINDERS"); } + [Fact] + public void Update_NoDueTimeWithRelativeReminder_ReturnsFailure() + { + var habit = CreateValidHabit(); + + var result = habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + RelativeReminders: [new RelativeReminderTime(MinutesBefore: -60)])); + + result.IsFailure.Should().BeTrue(); + habit.RelativeReminders.Should().BeEmpty(); + } + [Fact] public void Update_MixedHabitWithNoReminderFields_FoldsExistingScheduledReminders() { From ef8125abea76af4f710d0766ec41572d836e2a79 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 26 Sep 2026 14:20:13 -0300 Subject: [PATCH 4/6] Keep folded clock reminders visible to legacy clients --- src/Orbit.Api/Controllers/SyncController.cs | 2 +- .../Habits/Queries/GetCalendarMonthQuery.cs | 2 +- .../Habits/Queries/GetHabitByIdQuery.cs | 2 +- .../Habits/Queries/GetHabitFullDetailQuery.cs | 2 +- .../Habits/Queries/HabitScheduleFilters.cs | 2 +- src/Orbit.Domain/Entities/Habit.cs | 12 +++++++++- .../Orbit.Domain.Tests/Entities/HabitTests.cs | 24 +++++++++++++++++++ 7 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/Orbit.Api/Controllers/SyncController.cs b/src/Orbit.Api/Controllers/SyncController.cs index 256ca0cbc..bf9d4306f 100644 --- a/src/Orbit.Api/Controllers/SyncController.cs +++ b/src/Orbit.Api/Controllers/SyncController.cs @@ -340,7 +340,7 @@ private static SyncHabitDto MapHabit(Habit habit) habit.IsFlexible, habit.SlipAlertEnabled, habit.ChecklistItems, - habit.ScheduledReminders, + habit.GetScheduledRemindersForLegacyClients(), habit.EndDate, habit.Position, habit.ParentHabitId, diff --git a/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs b/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs index 53cadafa2..168eaf7cd 100644 --- a/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetCalendarMonthQuery.cs @@ -187,7 +187,7 @@ private static HabitScheduleItem MapToScheduleItem( habit.Days.ToList(), habit.Position, habit.CreatedAtUtc, habit.DueDate, habit.DueTime, habit.DueEndTime, habit.EndDate, scheduledDates, isOverdue, - habit.ReminderEnabled, habit.ReminderTimes, habit.ScheduledReminders, + habit.ReminderEnabled, habit.ReminderTimes, habit.GetScheduledRemindersForLegacyClients(), habit.SlipAlertEnabled, habit.ChecklistItems, habit.Tags.Select(t => new HabitTagItem(t.Id, t.Name, t.Color)).ToList(), habit.Goals.Select(g => new LinkedGoalDto(g.Id, g.Title)).ToList(), diff --git a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs index aa0eb4509..758687385 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitByIdQuery.cs @@ -118,7 +118,7 @@ public async Task> Handle(GetHabitByIdQuery request, habit.Position, habit.ReminderEnabled, habit.ReminderTimes, - habit.ScheduledReminders, + habit.GetScheduledRemindersForLegacyClients(), habit.ChecklistItems, habit.CreatedAtUtc, children, diff --git a/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs index 45022730c..92243928d 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitFullDetailQuery.cs @@ -71,7 +71,7 @@ public async Task> Handle(GetHabitFullDetailQuer habit.IsBadHabit, isCompleted, habit.IsGeneral, habit.IsFlexible, habit.DueDate, habit.DueTime, habit.DueEndTime, habit.EndDate, habit.Days.ToList(), habit.Position, - habit.ReminderEnabled, habit.ReminderTimes, habit.ScheduledReminders, + habit.ReminderEnabled, habit.ReminderTimes, habit.GetScheduledRemindersForLegacyClients(), habit.ChecklistItems, habit.CreatedAtUtc, children, Emoji: habit.Emoji, IntervalWeeks: habit.IntervalWeeks, diff --git a/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs index 78e9db587..1c213d123 100644 --- a/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs +++ b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs @@ -291,7 +291,7 @@ internal static HabitScheduleItem MapToScheduleItem( h.Days.ToList(), h.Position, h.CreatedAtUtc, h.DueDate, h.DueTime, h.DueEndTime, h.EndDate, scheduledDates, isOverdue, - h.ReminderEnabled, h.ReminderTimes, h.ScheduledReminders, h.SlipAlertEnabled, + h.ReminderEnabled, h.ReminderTimes, h.GetScheduledRemindersForLegacyClients(), h.SlipAlertEnabled, h.ChecklistItems, MapTags(h), MapGoals(h), MapChildren(h.Id, ctx), HasSubHabits(h.Id, ctx.ChildLookup), diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index 421495a55..f0fa9e239 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -91,6 +91,12 @@ public class Habit : Entity, ITimestamped, ISoftDeletable, IHabitSchedule public IReadOnlyList ChecklistItems { get; private set; } = []; public IReadOnlyList ScheduledReminders { get; private set; } = []; public IReadOnlyList RelativeReminders { get; private set; } = []; + public IReadOnlyList GetScheduledRemindersForLegacyClients() => + DueTime.HasValue + ? RelativeReminders.Where(r => r.When.HasValue) + .Select(r => new ScheduledReminderTime(r.When!.Value, r.Time!.Value)) + .ToList() + : ScheduledReminders; public DateOnly? EndDate { get; private set; } public int? Position { get; private set; } public string? GoogleEventId { get; private set; } @@ -602,7 +608,11 @@ private void ApplyOptionalUpdates(HabitUpdateParams p) if (DueTime.HasValue && ScheduledReminders.Count > 0) { - RelativeReminders = RelativeReminders.Concat(ScheduledReminders.Select(RelativeReminderTime.FromScheduled)).Distinct().ToList(); + var clockReminders = ScheduledReminders.Select(RelativeReminderTime.FromScheduled); + RelativeReminders = (p.ScheduledReminders is not null && p.RelativeReminders is null + ? RelativeReminders.Where(r => !r.When.HasValue) + : RelativeReminders) + .Concat(clockReminders).Distinct().ToList(); ScheduledReminders = []; } diff --git a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs index 1865a8c83..5c61fa7cb 100644 --- a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs @@ -1095,6 +1095,11 @@ public void Create_DueTimeWithScheduledReminders_FoldsEveryClockReminder() new RelativeReminderTime(When: ScheduledReminderWhen.SameDay, Time: new TimeOnly(10, 0)), new RelativeReminderTime(When: ScheduledReminderWhen.DayBefore, Time: new TimeOnly(20, 0)) ]); + result.Value.GetScheduledRemindersForLegacyClients().Should().BeEquivalentTo( + [ + new ScheduledReminderTime(ScheduledReminderWhen.SameDay, new TimeOnly(10, 0)), + new ScheduledReminderTime(ScheduledReminderWhen.DayBefore, new TimeOnly(20, 0)) + ]); } [Fact] @@ -1141,6 +1146,25 @@ public void Update_MixedHabitWithNoReminderFields_FoldsExistingScheduledReminder r.When == ScheduledReminderWhen.DayBefore && r.Time == new TimeOnly(20, 0)); } + [Fact] + public void Update_LegacyScheduledEdit_ReplacesRelativeClockReminders() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), + ScheduledReminders: [new(ScheduledReminderWhen.SameDay, new TimeOnly(8, 0))])).Value; + + var result = habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + DueTime: habit.DueTime, + ScheduledReminders: [new(ScheduledReminderWhen.DayBefore, new TimeOnly(20, 0))])); + + result.IsSuccess.Should().BeTrue(); + habit.ScheduledReminders.Should().BeEmpty(); + habit.RelativeReminders.Should().ContainSingle(r => + r.When == ScheduledReminderWhen.DayBefore && r.Time == new TimeOnly(20, 0)); + } + [Fact] public void Update_AllOptionalFields_Applied() { From 910e9d837769d9aadaec1e4bc9c44c210e105cdd Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 26 Sep 2026 15:50:11 -0300 Subject: [PATCH 5/6] Fix relative reminder defaults and delivery deduplication --- src/Orbit.Domain/Entities/Habit.cs | 21 +++--- .../Services/ReminderSchedulerService.cs | 12 +++- .../Habits/GetHabitByIdQueryHandlerTests.cs | 25 +++++++ .../Orbit.Domain.Tests/Entities/HabitTests.cs | 66 +++++++++++++++++++ .../Services/ReminderSchedulerServiceTests.cs | 35 ++++++++++ 5 files changed, 149 insertions(+), 10 deletions(-) diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index e12ff599a..d424ea9a5 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -225,7 +225,7 @@ public static Result Create(HabitCreateParams p) DueEndTime = p.DueEndTime, ParentHabitId = p.ParentHabitId, ReminderEnabled = p.ReminderEnabled, - ReminderTimes = p.ReminderTimes ?? (p.DueTime.HasValue && p.ScheduledReminders is { Count: > 0 } ? [] : [15]), + ReminderTimes = p.ReminderTimes ?? (p.DueTime.HasValue && (p.ScheduledReminders is { Count: > 0 } || p.RelativeReminders is { Count: > 0 }) ? [] : [15]), SlipAlertEnabled = p.SlipAlertEnabled, ChecklistItems = p.ChecklistItems ?? [], ScheduledReminders = p.DueTime.HasValue ? [] : p.ScheduledReminders ?? [], @@ -622,7 +622,11 @@ public Result ValidateUpdate(HabitUpdateParams p) if (!p.DueTime.HasValue && !DueTime.HasValue && p.RelativeReminders is { Count: > 0 }) return DomainErrors.InvalidRelativeReminders; - if (p.DueTime.HasValue && (p.RelativeReminders?.Count ?? RelativeReminders.Count) + (p.ScheduledReminders?.Count ?? ScheduledReminders.Count) > DomainConstants.MaxRelativeReminders) + var relativeCount = p.RelativeReminders is not null + ? p.RelativeReminders.Count + : RelativeReminders.Count(r => !r.When.HasValue) + + (p.ScheduledReminders?.Count ?? RelativeReminders.Count(r => r.When.HasValue) + ScheduledReminders.Count); + if (p.DueTime.HasValue && relativeCount > DomainConstants.MaxRelativeReminders) return DomainErrors.InvalidRelativeReminders; return HabitInvariants.ValidateReminderTimes(p.ReminderTimes); @@ -684,13 +688,14 @@ private void ApplyOptionalUpdates(HabitUpdateParams p) if (p.RelativeReminders is not null) RelativeReminders = p.RelativeReminders; - if (DueTime.HasValue && ScheduledReminders.Count > 0) + if (DueTime.HasValue) { - var clockReminders = ScheduledReminders.Select(RelativeReminderTime.FromScheduled); - RelativeReminders = (p.ScheduledReminders is not null && p.RelativeReminders is null - ? RelativeReminders.Where(r => !r.When.HasValue) - : RelativeReminders) - .Concat(clockReminders).Distinct().ToList(); + if (p.RelativeReminders is null && p.ScheduledReminders is not null) + RelativeReminders = RelativeReminders.Where(r => !r.When.HasValue) + .Concat(p.ScheduledReminders.Select(RelativeReminderTime.FromScheduled)).Distinct().ToList(); + else if (p.RelativeReminders is null && ScheduledReminders.Count > 0) + RelativeReminders = RelativeReminders.Concat(ScheduledReminders.Select(RelativeReminderTime.FromScheduled)) + .Distinct().ToList(); ScheduledReminders = []; } diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index 8f24349e0..765f4ab0f 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -179,6 +179,11 @@ private async Task ProcessSingleRelativeReminderAsync( var tz = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, user.Id); var userNow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz); var userToday = DateOnly.FromDateTime(userNow); + var sentInstants = sentReminderSet.Where(r => r.HabitId == habit.Id) + .Select(r => LocalDueInstantUtc(r.Date.ToDateTime(habit.DueTime!.Value), tz).AddMinutes(-r.MinutesBefore)) + .Concat(sentClockSet.Where(r => r.HabitId == habit.Id) + .Select(r => LocalReminderInstantUtc(r.Date.ToDateTime(r.Time), tz))) + .ToHashSet(); for (var dayOffset = -1; dayOffset <= MaxRelativeLookaheadDays; dayOffset++) { @@ -193,7 +198,8 @@ private async Task ProcessSingleRelativeReminderAsync( var reminderUtc = dueUtc.AddMinutes(-minutesBefore); var reminderLocalDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(reminderUtc, tz)); if (reminderLocalDate != userToday || nowUtc < reminderUtc) continue; - if (sentReminderSet.Contains((habit.Id, occurrenceDate, minutesBefore))) continue; + if (sentReminderSet.Contains((habit.Id, occurrenceDate, minutesBefore)) + || sentInstants.Contains(reminderUtc)) continue; var lang = user.Language ?? "en"; var minutesText = minutesBefore < 0 @@ -209,6 +215,7 @@ private async Task ProcessSingleRelativeReminderAsync( if (!await TryRecordReminderAsync(habit, sentReminder, notification, dbContext, ct)) continue; + sentInstants.Add(reminderUtc); pending.Add(new PendingReminderPush(habit.UserId, habit.Title, minutesText, habit.Id)); if (logger.IsEnabled(LogLevel.Debug)) @@ -226,7 +233,7 @@ private async Task ProcessSingleRelativeReminderAsync( if (nowUtc < sendUtc) continue; var clockKey = (habit.Id, sendDate, reminder.Time.Value, reminder.When); - if (sentClockSet.Contains(clockKey) + if (sentInstants.Contains(sendUtc) || sentClockSet.Contains(clockKey) || sentClockSet.Contains((habit.Id, sendDate, reminder.Time.Value, null))) continue; var lang = user.Language ?? "en"; @@ -238,6 +245,7 @@ private async Task ProcessSingleRelativeReminderAsync( if (!await TryRecordReminderAsync(habit, sentReminder, notification, dbContext, ct)) continue; + sentInstants.Add(sendUtc); pending.Add(new PendingReminderPush(habit.UserId, habit.Title, body, habit.Id)); } } diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs index 27afacc34..66ea979e5 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetHabitByIdQueryHandlerTests.cs @@ -5,6 +5,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Domain.ValueObjects; using Orbit.Infrastructure.Persistence; using System.Linq.Expressions; using System.Reflection; @@ -81,6 +82,30 @@ public async Task Handle_HabitFound_ReturnsSuccess() result.Value.DueDate.Should().Be(Today); } + [Fact] + public async Task Handle_LegacyEmptyScheduledEdit_DoesNotReturnFoldedClockReminder() + { + var habit = Habit.Create(new HabitCreateParams( + UserId, "Timed Habit", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), ReminderTimes: [30], + ScheduledReminders: [new ScheduledReminderTime(ScheduledReminderWhen.SameDay, new TimeOnly(8, 45))])).Value; + habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + DueTime: habit.DueTime, ScheduledReminders: [])).IsSuccess.Should().BeTrue(); + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(new List { habit }.AsReadOnly()); + + var result = await _handler.Handle(new GetHabitByIdQuery(UserId, habit.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.ScheduledReminders.Should().BeEmpty(); + result.Value.RelativeReminders.Should().BeEmpty(); + result.Value.ReminderTimes.Should().Equal(30); + } + [Fact] public async Task Handle_HabitNotFound_ReturnsFailure() { diff --git a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs index 5c61fa7cb..cec7e176e 100644 --- a/tests/Orbit.Domain.Tests/Entities/HabitTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/HabitTests.cs @@ -1102,6 +1102,18 @@ public void Create_DueTimeWithScheduledReminders_FoldsEveryClockReminder() ]); } + [Fact] + public void Create_DueTimeWithRelativeClockReminder_HasNoImplicitOffset() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), + RelativeReminders: [new RelativeReminderTime(When: ScheduledReminderWhen.SameDay, Time: new TimeOnly(8, 45))])).Value; + + habit.ReminderTimes.Should().BeEmpty(); + habit.RelativeReminders.Should().ContainSingle(); + } + [Fact] public void Create_InvalidSignedOffset_ReturnsFailure() { @@ -1165,6 +1177,60 @@ public void Update_LegacyScheduledEdit_ReplacesRelativeClockReminders() r.When == ScheduledReminderWhen.DayBefore && r.Time == new TimeOnly(20, 0)); } + [Fact] + public void Update_LegacyEmptyScheduledEdit_ClearsClockRulesAndLegacyRead() + { + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), ReminderTimes: [30], + RelativeReminders: [new RelativeReminderTime(When: ScheduledReminderWhen.SameDay, Time: new TimeOnly(8, 45))])).Value; + habit.GetScheduledRemindersForLegacyClients().Should().ContainSingle(); + + var result = habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + DueTime: habit.DueTime, ScheduledReminders: [])); + + result.IsSuccess.Should().BeTrue(); + habit.ReminderTimes.Should().Equal(30); + habit.RelativeReminders.Should().BeEmpty(); + habit.GetScheduledRemindersForLegacyClients().Should().BeEmpty(); + } + + [Fact] + public void Update_LegacyScheduledEditWithExistingClock_KeepsClockRules() + { + var clock = new ScheduledReminderTime(ScheduledReminderWhen.SameDay, new TimeOnly(8, 45)); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), ReminderTimes: [30], ScheduledReminders: [clock])).Value; + + var result = habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + DueTime: habit.DueTime, ScheduledReminders: [clock])); + + result.IsSuccess.Should().BeTrue(); + habit.ReminderTimes.Should().Equal(30); + habit.GetScheduledRemindersForLegacyClients().Should().Equal(clock); + } + + [Fact] + public void Update_ExplicitRelativeRules_IgnoreLegacyScheduledField() + { + var oldClock = new ScheduledReminderTime(ScheduledReminderWhen.SameDay, new TimeOnly(8, 45)); + var newClock = new RelativeReminderTime(When: ScheduledReminderWhen.DayBefore, Time: new TimeOnly(20, 0)); + var habit = Habit.Create(new HabitCreateParams( + ValidUserId, "Exercise", FrequencyUnit.Day, 1, Today, + DueTime: new TimeOnly(9, 0), ScheduledReminders: [oldClock])).Value; + + var result = habit.Update(new HabitUpdateParams( + habit.Title, habit.Description, FrequencyUnit.Day, 1, null, false, null, + DueTime: habit.DueTime, ScheduledReminders: [oldClock], RelativeReminders: [newClock])); + + result.IsSuccess.Should().BeTrue(); + habit.RelativeReminders.Should().Equal(newClock); + habit.GetScheduledRemindersForLegacyClients().Should().Equal(new ScheduledReminderTime(ScheduledReminderWhen.DayBefore, new TimeOnly(20, 0))); + } + [Fact] public void Update_AllOptionalFields_Applied() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs index a6d916054..14ee34550 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs @@ -536,6 +536,41 @@ await pushService.Received(1).SendToUserAsync( user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CheckAndSendReminders_RelativeClockAtOffsetInstant_DeliversOnce(bool explicitOffset) + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + var instant = new DateTimeOffset(2027, 9, 26, 8, 45, 0, TimeSpan.Zero); + var clock = new MutableTimeProvider(instant.AddMinutes(-1)); + var user = User.Create("Alex", "alex@test.com").Value; + var habit = Habit.Create(new HabitCreateParams( + user.Id, "Workout", FrequencyUnit.Day, 1, + DueDate: new DateOnly(2027, 9, 26), + DueTime: new TimeOnly(9, 0), + ReminderEnabled: true, + ReminderTimes: explicitOffset ? [15] : null, + RelativeReminders: [new RelativeReminderTime(When: ScheduledReminderWhen.SameDay, Time: new TimeOnly(8, 45))])).Value; + + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService, clock); + await service.CheckAndSendReminders(CancellationToken.None); + (await dbContext.SentReminders.CountAsync(r => r.HabitId == habit.Id)).Should().Be(0); + + clock.SetUtcNow(instant); + await service.CheckAndSendReminders(CancellationToken.None); + await service.CheckAndSendReminders(CancellationToken.None); + + (await dbContext.SentReminders.CountAsync(r => r.HabitId == habit.Id)).Should().Be(1); + await pushService.Received(1).SendToUserAsync( + user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); + } + [Fact] public async Task CheckAndSendReminders_SignedOffsetAfterDueTime_FiresOnce() { From 38e92eaf687e73aea7a266b43ff0116e9d636a3b Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 26 Sep 2026 16:25:18 -0300 Subject: [PATCH 6/6] Fix repeat sends for after-due reminders across UTC boundary --- .../Services/ReminderSchedulerService.cs | 6 ++--- .../Services/ReminderSchedulerServiceTests.cs | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index 765f4ab0f..be71cd313 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -23,6 +23,7 @@ public partial class ReminderSchedulerService( IConfiguration configuration, TimeProvider? timeProvider = null) : ScheduledServiceBase, IScheduledJob { + private const int MinRelativeDayOffset = -1; private const int MaxRelativeLookaheadDays = DomainConstants.MaxReminderMinutesBefore / 1440 + 1; private static readonly Expression> HabitProjection = h => new SchedulerHabit @@ -105,8 +106,7 @@ private async Task ProcessRelativeReminders(OrbitDbContext dbContext, List u.Id, ct); var habitIds = habits.Select(h => h.Id).ToList(); - var utcToday = DateOnly.FromDateTime(nowUtc); - var minWindowDate = utcToday.AddDays(-1); + var minWindowDate = minLocalDate.AddDays(MinRelativeDayOffset); var maxWindowDate = maxLocalDate; var loggedHabitDates = (await dbContext.HabitLogs @@ -185,7 +185,7 @@ private async Task ProcessSingleRelativeReminderAsync( .Select(r => LocalReminderInstantUtc(r.Date.ToDateTime(r.Time), tz))) .ToHashSet(); - for (var dayOffset = -1; dayOffset <= MaxRelativeLookaheadDays; dayOffset++) + for (var dayOffset = MinRelativeDayOffset; dayOffset <= MaxRelativeLookaheadDays; dayOffset++) { var occurrenceDate = userToday.AddDays(dayOffset); if (!HabitScheduleService.IsHabitDueOnDate(habit, occurrenceDate, user.WeekStartDay)) continue; diff --git a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs index 14ee34550..65a11d0ae 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs @@ -571,20 +571,25 @@ await pushService.Received(1).SendToUserAsync( user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); } - [Fact] - public async Task CheckAndSendReminders_SignedOffsetAfterDueTime_FiresOnce() + [Theory] + [InlineData("2027-09-26T10:00:00Z", "UTC", "2027-09-26", 9, 0, -60)] + [InlineData("2027-09-26T02:00:00Z", "America/Los_Angeles", "2027-09-24", 23, 0, -1200)] + public async Task CheckAndSendReminders_SignedOffsetAfterDueTime_FiresOnce( + string instant, string timeZoneId, string dueDateText, int dueHour, int dueMinute, int minutesBefore) { await using var dbContext = CreateInMemoryDbContext(); var pushService = Substitute.For(); - var clock = new MutableTimeProvider(new DateTimeOffset(2027, 9, 26, 9, 59, 0, TimeSpan.Zero)); + var reminderInstant = DateTimeOffset.Parse(instant); + var clock = new MutableTimeProvider(reminderInstant.AddMinutes(-1)); var user = User.Create("Alex", "alex@test.com").Value; + user.SetTimeZone(timeZoneId).IsSuccess.Should().BeTrue(); var habit = Habit.Create(new HabitCreateParams( user.Id, "Workout", FrequencyUnit.Day, 1, - DueDate: new DateOnly(2027, 9, 26), - DueTime: new TimeOnly(9, 0), + DueDate: DateOnly.Parse(dueDateText), + DueTime: new TimeOnly(dueHour, dueMinute), ReminderEnabled: true, ReminderTimes: [], - RelativeReminders: [new RelativeReminderTime(MinutesBefore: -60)])).Value; + RelativeReminders: [new RelativeReminderTime(MinutesBefore: minutesBefore)])).Value; dbContext.Users.Add(user); dbContext.Habits.Add(habit); await dbContext.SaveChangesAsync(); @@ -593,13 +598,16 @@ public async Task CheckAndSendReminders_SignedOffsetAfterDueTime_FiresOnce() await service.CheckAndSendReminders(CancellationToken.None); (await dbContext.SentReminders.CountAsync(r => r.HabitId == habit.Id)).Should().Be(0); - clock.SetUtcNow(new DateTimeOffset(2027, 9, 26, 10, 0, 0, TimeSpan.Zero)); + clock.SetUtcNow(reminderInstant); + await service.CheckAndSendReminders(CancellationToken.None); + clock.SetUtcNow(reminderInstant.AddMinutes(1)); await service.CheckAndSendReminders(CancellationToken.None); + clock.SetUtcNow(reminderInstant.AddMinutes(2)); await service.CheckAndSendReminders(CancellationToken.None); var sent = await dbContext.SentReminders.Where(r => r.HabitId == habit.Id).ToListAsync(); sent.Should().ContainSingle(); - sent[0].MinutesBefore.Should().Be(-60); + sent[0].MinutesBefore.Should().Be(minutesBefore); await pushService.Received(1).SendToUserAsync( user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); }