Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/Orbit.Api/Controllers/HabitsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ public async Task<IActionResult> 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,
Expand Down Expand Up @@ -307,7 +308,8 @@ public async Task<IActionResult> 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);
Expand Down Expand Up @@ -555,7 +557,8 @@ public async Task<IActionResult> 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);

Expand Down Expand Up @@ -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}")]
Expand Down
12 changes: 8 additions & 4 deletions src/Orbit.Api/Controllers/HabitsControllerRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public record CreateHabitRequest(
bool IsFlexible = false,
IReadOnlyList<Guid>? GoalIds = null,
string? Emoji = null,
int? IntervalWeeks = null);
int? IntervalWeeks = null,
IReadOnlyList<RelativeReminderTime>? RelativeReminders = null);

public record UpdateHabitRequest(
string Title,
Expand All @@ -50,7 +51,8 @@ public record UpdateHabitRequest(
bool? IsFlexible = null,
IReadOnlyList<Guid>? GoalIds = null,
string? Emoji = null,
int? IntervalWeeks = null);
int? IntervalWeeks = null,
IReadOnlyList<RelativeReminderTime>? RelativeReminders = null);

public record UpdateChecklistRequest(IReadOnlyList<ChecklistItem> ChecklistItems);

Expand Down Expand Up @@ -83,7 +85,8 @@ public record BulkHabitItemRequest(
string? GoogleEventId = null,
string? Emoji = null,
IReadOnlyList<string>? Tags = null,
int? IntervalWeeks = null);
int? IntervalWeeks = null,
IReadOnlyList<RelativeReminderTime>? RelativeReminders = null);

public record BulkDeleteHabitsRequest(IReadOnlyList<Guid> HabitIds);

Expand Down Expand Up @@ -132,7 +135,8 @@ public record CreateSubHabitRequest(
IReadOnlyList<Guid>? TagIds = null,
DateOnly? EndDate = null,
bool IsFlexible = false,
string? Emoji = null);
string? Emoji = null,
IReadOnlyList<RelativeReminderTime>? RelativeReminders = null);

public record LinkGoalsRequest(List<Guid> GoalIds);

Expand Down
8 changes: 5 additions & 3 deletions src/Orbit.Api/Controllers/SyncController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public record SyncHabitDto(
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
string? Emoji = null,
int? IntervalWeeks = null);
int? IntervalWeeks = null,
IReadOnlyList<Orbit.Domain.ValueObjects.RelativeReminderTime>? RelativeReminders = null);

public record SyncHabitLogDto(Guid Id, Guid HabitId, DateOnly Date, decimal Value, DateTime CreatedAtUtc, DateTime UpdatedAtUtc);

Expand Down Expand Up @@ -339,14 +340,15 @@ private static SyncHabitDto MapHabit(Habit habit)
habit.IsFlexible,
habit.SlipAlertEnabled,
habit.ChecklistItems,
habit.ScheduledReminders,
habit.GetScheduledRemindersForLegacyClients(),
habit.EndDate,
habit.Position,
habit.ParentHabitId,
habit.CreatedAtUtc,
habit.UpdatedAtUtc,
Emoji: habit.Emoji,
IntervalWeeks: habit.IntervalWeeks);
IntervalWeeks: habit.IntervalWeeks,
RelativeReminders: habit.RelativeReminders);
}

private static SyncHabitLogDto MapHabitLog(HabitLog habitLog)
Expand Down
67 changes: 67 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13577,6 +13577,15 @@
"string"
],
"format": "int32"
},
"relativeReminders": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/RelativeReminderTime"
}
}
}
},
Expand Down Expand Up @@ -14149,6 +14158,15 @@
"string"
],
"format": "int32"
},
"relativeReminders": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/RelativeReminderTime"
}
}
}
},
Expand Down Expand Up @@ -14286,6 +14304,15 @@
"null",
"string"
]
},
"relativeReminders": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/RelativeReminderTime"
}
}
}
},
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -16200,6 +16258,15 @@
"string"
],
"format": "int32"
},
"relativeReminders": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/RelativeReminderTime"
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,8 @@ private static Domain.Common.Result<Habit> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,8 @@ public async Task<ToolResult> 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");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,6 @@ internal static class ReminderStoreNormalizer
{
private const int MinutesPerDay = 24 * 60;

/// <summary>
/// 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.
/// </summary>
public static (List<int>? ReminderTimes, List<ScheduledReminderTime>? ScheduledReminders) Normalize(
TimeOnly? dueTime,
List<int>? reminderTimes,
List<ScheduledReminderTime>? 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<int>(reminderTimes) : new List<int>();

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<int>? ReminderTimes, List<ScheduledReminderTime>? ScheduledReminders) NormalizeForUpdate(
TimeOnly? newDueTime,
TimeOnly? previousDueTime,
Expand All @@ -48,29 +16,17 @@ public static (List<int>? ReminderTimes, List<ScheduledReminderTime>? ScheduledR
IReadOnlyList<ScheduledReminderTime> 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));

return (null, null);
}

private static List<int> ToOffsets(TimeOnly dueTime, IReadOnlyList<ScheduledReminderTime> reminders)
{
var offsets = new List<int>();
foreach (var reminder in reminders)
{
var offset = ToMinutesBeforeDueTime(dueTime, reminder);
if (!offsets.Contains(offset))
offsets.Add(offset);
}
return offsets;
}

private static List<ScheduledReminderTime> ToScheduledReminders(TimeOnly dueTime, IReadOnlyList<int> offsets)
{
var reminders = new List<ScheduledReminderTime>();
Expand All @@ -84,19 +40,6 @@ private static List<ScheduledReminderTime> ToScheduledReminders(TimeOnly dueTime
return reminders;
}

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ public record BulkHabitItem(
string? GoogleEventId = null,
string? Emoji = null,
IReadOnlyList<string>? Tags = null,
int? IntervalWeeks = null);
int? IntervalWeeks = null,
IReadOnlyList<RelativeReminderTime>? RelativeReminders = null);

public record BulkCreateResult(IReadOnlyList<BulkCreateItemResult> Results);

Expand Down Expand Up @@ -157,6 +158,7 @@ private async Task<BulkCreateItemResult> CreateSingleHabit(
IsGeneral: item.IsGeneral,
IsFlexible: item.IsFlexible,
ScheduledReminders: item.ScheduledReminders,
RelativeReminders: item.RelativeReminders,
ChecklistItems: item.ChecklistItems,
Position: rootPosition,
GoogleEventId: item.GoogleEventId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ private async Task<Result<Guid>> HandleLockedAsync(
IsGeneral: request.IsGeneral,
IsFlexible: opts.IsFlexible,
ScheduledReminders: opts.ScheduledReminders,
RelativeReminders: opts.RelativeReminders,
Position: nextPosition,
IntervalWeeks: request.IntervalWeeks));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ public async Task<Result<Guid>> Handle(CreateSubHabitCommand request, Cancellati
IsFlexible: opts.IsFlexible,
EndDate: opts.EndDate,
ScheduledReminders: opts.ScheduledReminders,
RelativeReminders: opts.RelativeReminders,
Position: nextPosition,
IntervalWeeks: intervalWeeks));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ private static Result<Habit> CloneHabit(
IsFlexible: source.IsFlexible,
EndDate: source.EndDate,
ScheduledReminders: source.ScheduledReminders,
RelativeReminders: source.RelativeReminders,
ReminderTimes: source.ReminderTimes,
Position: position,
IntervalWeeks: source.IntervalWeeks));

Expand Down
6 changes: 4 additions & 2 deletions src/Orbit.Application/Habits/Commands/HabitCommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public record HabitCommandOptions(
IReadOnlyList<ChecklistItem>? ChecklistItems = null,
IReadOnlyList<ScheduledReminderTime>? ScheduledReminders = null,
DateOnly? EndDate = null,
bool IsFlexible = false);
bool IsFlexible = false,
IReadOnlyList<RelativeReminderTime>? RelativeReminders = null);

/// <summary>
/// Optional settings for UpdateHabitCommand. Uses nullable booleans
Expand All @@ -32,4 +33,5 @@ public record UpdateHabitCommandOptions(
IReadOnlyList<ChecklistItem>? ChecklistItems = null,
IReadOnlyList<ScheduledReminderTime>? ScheduledReminders = null,
DateOnly? EndDate = null,
bool? IsFlexible = null);
bool? IsFlexible = null,
IReadOnlyList<RelativeReminderTime>? RelativeReminders = null);
Loading
Loading