From ec1a5bc6136502f494604d93ace695757d49000f Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 25 Jun 2026 02:10:49 -0300 Subject: [PATCH] feat(calendar+retro): all-owned-calendar sync, retrospective caps, server-derived clock format Calendar: read every owned calendar (accessRole=owner) instead of only "primary", so events in secondary calendars (e.g. "Rotina") sync. CalendarList -> per-calendar Events.list with pagination + merge; per-calendar recurring-master dedup; events tagged with calendarId / calendarName. Adds GoogleCalendarSelectedIds preference (+ migration), GET /api/calendar/calendars and PUT /api/calendar/selected-calendars for the settings picker. Retrospective: cap per-habit, aggregate, and weekly completion at 100% (over-logging no longer reads as 200-300%); flag one-time tasks (IsOneTime) so clients render done/not-done instead of "0%". AI-narrative percentages capped too. Profile: return Uses24HourClock derived server-side from the user's IANA timezone, so clients render 12h/24h by region with no client-side lookup table. 3719 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/CalendarController.cs | 29 + .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Commands/RunCalendarAutoSyncCommand.cs | 3 +- .../Commands/SetSelectedCalendarsCommand.cs | 31 + .../Queries/GetCalendarEventsQuery.cs | 7 +- .../Calendar/Queries/GetUserCalendarsQuery.cs | 85 + .../Services/ICalendarEventFetcher.cs | 40 +- .../SetSelectedCalendarsCommandValidator.cs | 22 + src/Orbit.Application/Common/AppConstants.cs | 1 + src/Orbit.Application/Common/ErrorCodes.cs | 1 + src/Orbit.Application/Common/ErrorMessages.cs | 1 + .../Common/TimeFormatResolver.cs | 48 + .../Habits/Queries/GetRetrospectiveQuery.cs | 3 +- .../RetrospectiveMetricsCalculator.cs | 9 +- .../Profile/Queries/GetProfileQuery.cs | 6 +- src/Orbit.Domain/Entities/User.cs | 40 + ...625040852_AddCalendarSelection.Designer.cs | 1779 +++++++++++++++++ .../20260625040852_AddCalendarSelection.cs | 28 + .../Migrations/OrbitDbContextModelSnapshot.cs | 3 + .../Orbit.Infrastructure.csproj | 1 + .../Persistence/OrbitDbContext.cs | 2 + .../Services/AgentCatalogService.cs | 6 +- .../Services/AiRetrospectiveService.cs | 4 +- ...frastructureServiceCollectionExtensions.cs | 19 + .../Services/Calendar/GoogleCalendarApi.cs | 93 + .../Services/Calendar/IGoogleCalendarApi.cs | 27 + .../Services/GoogleCalendarEventFetcher.cs | 184 +- .../RunCalendarAutoSyncCommandHandlerTests.cs | 32 +- ...SetSelectedCalendarsCommandHandlerTests.cs | 105 + .../Common/TimeFormatResolverTests.cs | 41 + .../GetCalendarEventsQueryHandlerTests.cs | 5 +- .../GetUserCalendarsQueryHandlerTests.cs | 130 ++ .../GetRetrospectiveQueryHandlerTests.cs | 17 + .../Profile/GetProfileQueryHandlerTests.cs | 34 + .../GoogleCalendarEventFetcherTests.cs | 215 ++ 35 files changed, 2948 insertions(+), 106 deletions(-) create mode 100644 src/Orbit.Application/Calendar/Commands/SetSelectedCalendarsCommand.cs create mode 100644 src/Orbit.Application/Calendar/Queries/GetUserCalendarsQuery.cs create mode 100644 src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs create mode 100644 src/Orbit.Application/Common/TimeFormatResolver.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260625040852_AddCalendarSelection.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260625040852_AddCalendarSelection.cs create mode 100644 src/Orbit.Infrastructure/Services/Calendar/CalendarInfrastructureServiceCollectionExtensions.cs create mode 100644 src/Orbit.Infrastructure/Services/Calendar/GoogleCalendarApi.cs create mode 100644 src/Orbit.Infrastructure/Services/Calendar/IGoogleCalendarApi.cs create mode 100644 tests/Orbit.Application.Tests/Commands/Calendar/SetSelectedCalendarsCommandHandlerTests.cs create mode 100644 tests/Orbit.Application.Tests/Common/TimeFormatResolverTests.cs create mode 100644 tests/Orbit.Application.Tests/Queries/Calendar/GetUserCalendarsQueryHandlerTests.cs create mode 100644 tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs diff --git a/src/Orbit.Api/Controllers/CalendarController.cs b/src/Orbit.Api/Controllers/CalendarController.cs index 4e31c4d1..b9c8e77d 100644 --- a/src/Orbit.Api/Controllers/CalendarController.cs +++ b/src/Orbit.Api/Controllers/CalendarController.cs @@ -43,6 +43,35 @@ public async Task GetEvents(CancellationToken cancellationToken) return result.ToPayGateAwareResult(value => Ok(value)); } + [HttpGet("calendars")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetCalendars(CancellationToken cancellationToken) + { + var query = new GetUserCalendarsQuery(HttpContext.GetUserId()); + var result = await mediator.Send(query, cancellationToken); + return result.ToPayGateAwareResult(value => Ok(value)); + } + + public record SetSelectedCalendarsRequest(IReadOnlyList? CalendarIds); + + [HttpPut("selected-calendars")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task SetSelectedCalendars( + [FromBody] SetSelectedCalendarsRequest request, + CancellationToken cancellationToken) + { + if (request.CalendarIds is null) + return BadRequest(ErrorMessages.CalendarIdsRequired.ToErrorBody()); + + var command = new SetSelectedCalendarsCommand(HttpContext.GetUserId(), request.CalendarIds); + var result = await mediator.Send(command, cancellationToken); + return result.ToPayGateAwareResult(() => NoContent()); + } + [HttpPut("dismiss")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index fc96d60d..0191cc78 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; using Orbit.Infrastructure.Services; +using Orbit.Infrastructure.Services.Calendar; using Scalar.AspNetCore; namespace Orbit.Api.Extensions; @@ -90,7 +91,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder sp.GetRequiredService>())); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddGoogleCalendarServices(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs index b0bd7d68..1f68c8d4 100644 --- a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs +++ b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs @@ -104,7 +104,8 @@ private async Task> FetchAndProcess( List fetched; try { - fetched = await deps.EventFetcher.FetchAsync(accessToken, updatedMin: null, ct); + fetched = await deps.EventFetcher.FetchAsync( + accessToken, user.GetSelectedCalendarIds(), updatedMin: null, ct); fetched = NormalizeFetchedEvents(user.Id, fetched); } catch (CalendarProviderException ex) when (ex.Kind == CalendarFetchErrorKind.ReconnectRequired) diff --git a/src/Orbit.Application/Calendar/Commands/SetSelectedCalendarsCommand.cs b/src/Orbit.Application/Calendar/Commands/SetSelectedCalendarsCommand.cs new file mode 100644 index 00000000..5eb8e9a0 --- /dev/null +++ b/src/Orbit.Application/Calendar/Commands/SetSelectedCalendarsCommand.cs @@ -0,0 +1,31 @@ +using MediatR; +using Orbit.Application.Behaviors; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Calendar.Commands; + +public record SetSelectedCalendarsCommand(Guid UserId, IReadOnlyList CalendarIds) + : IRequest, IConcurrencyRetryable; + +public class SetSelectedCalendarsCommandHandler( + IGenericRepository userRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(SetSelectedCalendarsCommand request, CancellationToken cancellationToken) + { + var user = await userRepository.FindOneTrackedAsync( + u => u.Id == request.UserId, + cancellationToken: cancellationToken); + + if (user is null) + return Result.Failure(ErrorMessages.UserNotFound); + + user.SetSelectedCalendars(request.CalendarIds); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 87dac53d..b8933296 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -19,7 +19,9 @@ public record CalendarEventItem( bool IsRecurring, string? RecurrenceRule, List Reminders, - DateTime? StartUtc = null); + DateTime? StartUtc = null, + string CalendarId = "", + string CalendarName = ""); public record GetCalendarEventsQuery(Guid UserId) : IRequest>>, IConcurrencyRetryable; @@ -51,7 +53,8 @@ public async Task>> Handle(GetCalendarEventsQuery try { - var fetched = await eventFetcher.FetchAsync(accessToken, updatedMin: null, cancellationToken); + var fetched = await eventFetcher.FetchAsync( + accessToken, user.GetSelectedCalendarIds(), updatedMin: null, cancellationToken); var importedEventIds = await BuildImportedEventIdSet(request.UserId, cancellationToken); var items = fetched diff --git a/src/Orbit.Application/Calendar/Queries/GetUserCalendarsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetUserCalendarsQuery.cs new file mode 100644 index 00000000..485878a7 --- /dev/null +++ b/src/Orbit.Application/Calendar/Queries/GetUserCalendarsQuery.cs @@ -0,0 +1,85 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using Orbit.Application.Behaviors; +using Orbit.Application.Calendar.Services; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Calendar.Queries; + +public record UserCalendarItem( + string Id, + string Name, + string AccessRole, + bool Primary, + string? BackgroundColor, + bool IsSynced); + +public record GetUserCalendarsQuery(Guid UserId) : IRequest>>, IConcurrencyRetryable; + +public partial class GetUserCalendarsQueryHandler( + IGenericRepository userRepository, + IPayGateService payGate, + IGoogleTokenService googleTokenService, + ICalendarEventFetcher eventFetcher, + IUnitOfWork unitOfWork, + ILogger logger) : IRequestHandler>> +{ + public async Task>> Handle(GetUserCalendarsQuery request, CancellationToken cancellationToken) + { + var gateCheck = await payGate.CanAccessCalendar(request.UserId, cancellationToken); + if (gateCheck.IsFailure) + return gateCheck.PropagateError>(); + + var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user is null) + return Result.Failure>(ErrorMessages.UserNotFound); + + var accessToken = await googleTokenService.GetValidAccessTokenAsync(user, cancellationToken); + if (accessToken is null) + return Result.Failure>(ErrorMessages.CalendarNotConnected); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + try + { + var calendars = await eventFetcher.ListCalendarsAsync(accessToken, cancellationToken); + var selectedIds = user.GetSelectedCalendarIds(); + return Result.Success(MapCalendars(calendars, selectedIds)); + } + catch (CalendarProviderException ex) + { + LogGoogleCalendarApiError(logger, ex, request.UserId); + if (ex.Kind == CalendarFetchErrorKind.ReconnectRequired) + { + user.MarkCalendarSyncReconnectRequired(ex.RawErrorCode ?? "reconnect_required"); + await unitOfWork.SaveChangesAsync(cancellationToken); + return Result.Failure>(ErrorMessages.CalendarReconnectRequired); + } + return Result.Failure>(ErrorMessages.CalendarFetchFailed); + } + } + + private static List MapCalendars( + IReadOnlyList calendars, IReadOnlyList? selectedIds) + { + var selected = selectedIds is null + ? null + : new HashSet(selectedIds, StringComparer.Ordinal); + + return calendars + .Select(c => new UserCalendarItem( + c.Id, + c.Name, + c.AccessRole, + c.Primary, + c.BackgroundColor, + selected is null ? c.IsDefaultOwned : selected.Contains(c.Id))) + .ToList(); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Google Calendar list error for user {UserId}")] + private static partial void LogGoogleCalendarApiError(ILogger logger, Exception ex, Guid userId); +} diff --git a/src/Orbit.Application/Calendar/Services/ICalendarEventFetcher.cs b/src/Orbit.Application/Calendar/Services/ICalendarEventFetcher.cs index 7036cb84..7b086701 100644 --- a/src/Orbit.Application/Calendar/Services/ICalendarEventFetcher.cs +++ b/src/Orbit.Application/Calendar/Services/ICalendarEventFetcher.cs @@ -5,22 +5,50 @@ namespace Orbit.Application.Calendar.Services; public interface ICalendarEventFetcher { /// - /// Fetches Google Calendar events from the user's primary calendar for the next 60 - /// days and maps them into instances. The concrete - /// implementation lives in Infrastructure and owns Google SDK construction, so - /// Application only passes an OAuth access token. - /// Throws on provider errors; the Kind field - /// tells callers whether to force a reconnect or retry later. + /// Fetches Google Calendar events for the next 60 days across the user's calendars and maps + /// them into instances tagged with their source calendar. + /// When is null the user's owned, non-deleted, + /// non-hidden calendars are used; when non-null only calendars whose id is in that set are + /// fetched (still skipping deleted/hidden/inaccessible ones). A single calendar that fails + /// (e.g. transient or permission error) is logged and skipped rather than failing the whole + /// fetch. The concrete implementation lives in Infrastructure and owns Google SDK construction, + /// so Application only passes an OAuth access token. + /// Throws when the initial calendar-list call fails; + /// the Kind field tells callers whether to force a reconnect or retry later. /// /// Google OAuth 2.0 access token for the calling user. + /// Explicit calendar-id allow-list, or null to use all owned calendars. /// If provided, Google returns only events created/modified after this UTC timestamp. /// Cancellation token. Task> FetchAsync( string accessToken, + IReadOnlyCollection? selectedCalendarIds, DateTime? updatedMin, CancellationToken ct); + + /// + /// Lists every non-deleted, non-hidden calendar on the user's calendar list as + /// instances for a settings picker. Throws + /// on provider errors. + /// + /// Google OAuth 2.0 access token for the calling user. + /// Cancellation token. + Task> ListCalendarsAsync(string accessToken, CancellationToken ct); } +/// +/// A single calendar from the user's Google calendar list, surfaced to the settings picker. +/// reflects the owner/!deleted/!hidden rule used to build the +/// default sync set when the user has no explicit selection. +/// +public record CalendarListItem( + string Id, + string Name, + string AccessRole, + bool Primary, + string? BackgroundColor, + bool IsDefaultOwned); + /// /// Classification of calendar-provider failures used by Application to decide whether /// to force the user to reconnect vs. mark a transient error for retry. diff --git a/src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs b/src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs new file mode 100644 index 00000000..51982c0f --- /dev/null +++ b/src/Orbit.Application/Calendar/Validators/SetSelectedCalendarsCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using Orbit.Application.Calendar.Commands; +using Orbit.Application.Common; + +namespace Orbit.Application.Calendar.Validators; + +public class SetSelectedCalendarsCommandValidator : AbstractValidator +{ + public SetSelectedCalendarsCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + RuleFor(x => x.CalendarIds) + .NotNull() + .Must(ids => ids.Count <= AppConstants.MaxSelectedCalendars) + .WithMessage($"You can select at most {AppConstants.MaxSelectedCalendars} calendars."); + + RuleForEach(x => x.CalendarIds) + .NotEmpty() + .WithMessage("Calendar ids must be non-empty."); + } +} diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index 7b90ba9d..46a6704d 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -48,6 +48,7 @@ public static class AppConstants public const long MaxAudioBytes = 26_214_400; public const int MaxAiToolResultTextLength = 12_000; public const int MaxCalendarRangeDays = 62; + public const int MaxSelectedCalendars = 50; public const int MaxPageSize = 200; public const int AdRewardBonusMessages = 5; public const int AdRewardDailyCap = 3; diff --git a/src/Orbit.Application/Common/ErrorCodes.cs b/src/Orbit.Application/Common/ErrorCodes.cs index 800e44b1..ee8fd933 100644 --- a/src/Orbit.Application/Common/ErrorCodes.cs +++ b/src/Orbit.Application/Common/ErrorCodes.cs @@ -76,6 +76,7 @@ public static class ErrorCodes public const string CalendarReconnectRequired = "CALENDAR_RECONNECT_REQUIRED"; public const string CalendarFetchFailed = "CALENDAR_FETCH_FAILED"; public const string AutoSyncEnabledRequired = "AUTO_SYNC_ENABLED_REQUIRED"; + public const string CalendarIdsRequired = "CALENDAR_IDS_REQUIRED"; public const string NoHabitsForPeriod = "NO_HABITS_FOR_PERIOD"; public const string AiSummaryDisabled = "AI_SUMMARY_DISABLED"; public const string NoActiveGoals = "NO_ACTIVE_GOALS"; diff --git a/src/Orbit.Application/Common/ErrorMessages.cs b/src/Orbit.Application/Common/ErrorMessages.cs index b3bc1c35..afd601a3 100644 --- a/src/Orbit.Application/Common/ErrorMessages.cs +++ b/src/Orbit.Application/Common/ErrorMessages.cs @@ -75,6 +75,7 @@ public static class ErrorMessages public static readonly AppError CalendarReconnectRequired = new(ErrorCodes.CalendarReconnectRequired, "Google Calendar connection expired. Please reconnect."); public static readonly AppError CalendarFetchFailed = new(ErrorCodes.CalendarFetchFailed, "Failed to fetch calendar events. Please try again."); public static readonly AppError AutoSyncEnabledRequired = new(ErrorCodes.AutoSyncEnabledRequired, "Enabled is required."); + public static readonly AppError CalendarIdsRequired = new(ErrorCodes.CalendarIdsRequired, "Calendar ids are required."); public static readonly AppError NoHabitsForPeriod = new(ErrorCodes.NoHabitsForPeriod, "No habits found for this period."); public static readonly AppError AiSummaryDisabled = new(ErrorCodes.AiSummaryDisabled, "AI summary is disabled."); public static readonly AppError NoActiveGoals = new(ErrorCodes.NoActiveGoals, "No active goals found."); diff --git a/src/Orbit.Application/Common/TimeFormatResolver.cs b/src/Orbit.Application/Common/TimeFormatResolver.cs new file mode 100644 index 00000000..232fd14d --- /dev/null +++ b/src/Orbit.Application/Common/TimeFormatResolver.cs @@ -0,0 +1,48 @@ +namespace Orbit.Application.Common; + +/// +/// Resolves whether a user's region uses a 24-hour clock, derived from their IANA +/// time zone. The 12-hour-clock zone set is sourced from the CLDR regions that default +/// to a 12-hour clock (the Americas' English/Spanish locales, Australia, New Zealand, +/// South and Southeast Asia, and a few others); every other zone, and a null or unknown +/// value, resolves to 24-hour, which is the global majority. +/// +public static class TimeFormatResolver +{ + private static readonly HashSet TwelveHourTimeZones = new(StringComparer.Ordinal) + { + "America/New_York", "America/Detroit", "America/Kentucky/Louisville", "America/Kentucky/Monticello", + "America/Indiana/Indianapolis", "America/Indiana/Vincennes", "America/Indiana/Winamac", + "America/Indiana/Marengo", "America/Indiana/Petersburg", "America/Indiana/Vevay", + "America/Chicago", "America/Indiana/Tell_City", "America/Indiana/Knox", "America/Menominee", + "America/North_Dakota/Center", "America/North_Dakota/New_Salem", "America/North_Dakota/Beulah", + "America/Denver", "America/Boise", "America/Phoenix", "America/Los_Angeles", "America/Anchorage", + "America/Juneau", "America/Sitka", "America/Metlakatla", "America/Yakutat", "America/Nome", + "America/Adak", "Pacific/Honolulu", + "America/St_Johns", "America/Halifax", "America/Glace_Bay", "America/Moncton", "America/Goose_Bay", + "America/Toronto", "America/Iqaluit", "America/Winnipeg", "America/Resolute", "America/Rankin_Inlet", + "America/Regina", "America/Swift_Current", "America/Edmonton", "America/Cambridge_Bay", + "America/Inuvik", "America/Creston", "America/Dawson_Creek", "America/Fort_Nelson", + "America/Vancouver", "America/Whitehorse", "America/Dawson", + "Australia/Sydney", "Australia/Melbourne", "Australia/Brisbane", "Australia/Perth", + "Australia/Adelaide", "Australia/Hobart", "Australia/Darwin", "Australia/Lord_Howe", + "Australia/Lindeman", "Australia/Broken_Hill", "Australia/Eucla", + "Pacific/Auckland", "Pacific/Chatham", + "Asia/Kolkata", "Asia/Karachi", "Asia/Dhaka", "Asia/Colombo", "Asia/Kathmandu", + "Asia/Manila", "Asia/Kuala_Lumpur", "Asia/Kuching", + "Africa/Cairo", "Asia/Riyadh", "Asia/Amman", + "America/Mexico_City", "America/Cancun", "America/Merida", "America/Monterrey", "America/Matamoros", + "America/Mazatlan", "America/Chihuahua", "America/Ojinaga", "America/Hermosillo", "America/Tijuana", + "America/Bahia_Banderas", + "America/Bogota", "America/El_Salvador", "America/Tegucigalpa", "America/Managua", + "America/Guatemala", "America/Costa_Rica", "America/Panama", "America/Santo_Domingo", + "America/Puerto_Rico" + }; + + /// + /// Returns true when the region for uses a 24-hour + /// clock. A null or unrecognized zone resolves to 24-hour. + /// + public static bool Uses24HourClock(string? ianaTimeZone) => + ianaTimeZone is null || !TwelveHourTimeZones.Contains(ianaTimeZone); +} diff --git a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs index a6f7d0b9..7e98b7ef 100644 --- a/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetRetrospectiveQuery.cs @@ -15,7 +15,8 @@ public record RetrospectiveHabitStat( string? Emoji, int CompletionRate, int CompletedCount, - int ScheduledCount); + int ScheduledCount, + bool IsOneTime = false); public record RetrospectiveMetrics( int CompletionRate, diff --git a/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs b/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs index 7ac7c85b..bc6208a8 100644 --- a/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs +++ b/src/Orbit.Application/Habits/Services/RetrospectiveMetricsCalculator.cs @@ -61,7 +61,7 @@ public static RetrospectiveMetrics Compute( stats.Add(BuildHabitStat(habit, scheduledDates.Count, completedCount)); } - var completionRate = Percent(totalCompletions, totalScheduled); + var completionRate = Math.Min(100, Percent(totalCompletions, totalScheduled)); var activeDays = CountActiveDays(habits, dateFrom, dateTo); var periodDays = dateTo.DayNumber - dateFrom.DayNumber + 1; var weeklyConsistency = BuildWeeklyConsistency(weekdayScheduled, weekdayCompleted); @@ -114,15 +114,16 @@ private static RetrospectiveHabitStat BuildHabitStat(Habit habit, int scheduledC new( habit.Title, habit.Emoji, - Percent(completedCount, scheduledCount), + Math.Min(100, Percent(completedCount, scheduledCount)), completedCount, - scheduledCount); + scheduledCount, + habit.FrequencyUnit is null); private static IReadOnlyList BuildWeeklyConsistency(int[] weekdayScheduled, int[] weekdayCompleted) { var consistency = new int[7]; for (var i = 0; i < 7; i++) - consistency[i] = Percent(weekdayCompleted[i], weekdayScheduled[i]); + consistency[i] = Math.Min(100, Percent(weekdayCompleted[i], weekdayScheduled[i])); return consistency; } diff --git a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs index e7345aae..dd0b1222 100644 --- a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs +++ b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs @@ -41,7 +41,8 @@ public record ProfileResponse( string? ColorScheme, bool GoogleCalendarAutoSyncEnabled, GoogleCalendarAutoSyncStatus GoogleCalendarAutoSyncStatus, - DateTime? GoogleCalendarLastSyncedAt); + DateTime? GoogleCalendarLastSyncedAt, + bool Uses24HourClock = true); public record GetProfileQuery(Guid UserId) : IRequest>; @@ -104,6 +105,7 @@ public async Task> Handle(GetProfileQuery request, Cance user.ColorScheme, user.GoogleCalendarAutoSyncEnabled, user.GoogleCalendarAutoSyncStatus ?? GoogleCalendarAutoSyncStatus.Idle, - user.GoogleCalendarLastSyncedAt)); + user.GoogleCalendarLastSyncedAt, + TimeFormatResolver.Uses24HourClock(user.TimeZone))); } } diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index 765a4dc1..d6c5109e 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -34,6 +34,7 @@ public partial class User : Entity public string? GoogleAccessToken { get; private set; } public string? GoogleRefreshToken { get; private set; } public bool GoogleCalendarAutoSyncEnabled { get; private set; } + public string? GoogleCalendarSelectedIds { get; private set; } public GoogleCalendarAutoSyncStatus? GoogleCalendarAutoSyncStatus { get; private set; } public DateTime? GoogleCalendarLastSyncedAt { get; private set; } public string? GoogleCalendarLastSyncError { get; private set; } @@ -133,6 +134,44 @@ public Result SetTimeZone(string ianaTimeZoneId) public void SetLanguage(string? language) => Language = language; + /// + /// Persists the user's Google Calendar selection as a JSON array of calendar ids. + /// A null means "all owned calendars" (the + /// default); an empty input list clears the selection back to that default. + /// + public void SetSelectedCalendars(IReadOnlyCollection calendarIds) + { + var normalized = calendarIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + GoogleCalendarSelectedIds = normalized.Count == 0 + ? null + : System.Text.Json.JsonSerializer.Serialize(normalized); + } + + /// + /// Deserializes into the user's chosen calendar + /// ids, or null when no explicit selection exists (callers then fall back to all owned + /// calendars). Malformed stored JSON is treated as "no selection". + /// + public IReadOnlyList? GetSelectedCalendarIds() + { + if (string.IsNullOrWhiteSpace(GoogleCalendarSelectedIds)) + return null; + + try + { + return System.Text.Json.JsonSerializer.Deserialize>(GoogleCalendarSelectedIds); + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + public Result SetThemePreference(string? preference) { if (preference is not null && preference is not ("dark" or "light")) @@ -427,6 +466,7 @@ public void ResetAccount() GoogleAccessToken = null; GoogleRefreshToken = null; GoogleCalendarAutoSyncEnabled = false; + GoogleCalendarSelectedIds = null; GoogleCalendarAutoSyncStatus = null; GoogleCalendarLastSyncedAt = null; GoogleCalendarLastSyncError = null; diff --git a/src/Orbit.Infrastructure/Migrations/20260625040852_AddCalendarSelection.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260625040852_AddCalendarSelection.Designer.cs new file mode 100644 index 00000000..42b8e637 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260625040852_AddCalendarSelection.Designer.cs @@ -0,0 +1,1779 @@ +// +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("20260625040852_AddCalendarSelection")] + partial class AddCalendarSelection + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .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.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.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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.ToTable("ChecklistTemplates"); + }); + + 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.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + 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.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.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(256) + .HasColumnType("character varying(256)"); + + 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(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("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.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("When") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When"), false); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + 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() + .HasColumnType("text"); + + 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(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + 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("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + 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("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + 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("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.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + 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.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.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .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); + }); + + 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.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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/20260625040852_AddCalendarSelection.cs b/src/Orbit.Infrastructure/Migrations/20260625040852_AddCalendarSelection.cs new file mode 100644 index 00000000..cd6baebc --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260625040852_AddCalendarSelection.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddCalendarSelection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GoogleCalendarSelectedIds", + table: "Users", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GoogleCalendarSelectedIds", + table: "Users"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 0aa0e2f4..dfb13841 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1399,6 +1399,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GoogleCalendarLastSyncedAt") .HasColumnType("timestamp with time zone"); + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + b.Property("GoogleCalendarSyncReconciledAt") .HasColumnType("timestamp with time zone"); diff --git a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj index f85cfbb4..65dc4511 100644 --- a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj +++ b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj @@ -30,6 +30,7 @@ + diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index e2e63da8..fc42241e 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -451,6 +451,8 @@ private static void ConfigureUserEntity(ModelBuilder modelBuilder, NullableEncry entity.Property(u => u.GoogleCalendarLastSyncError).HasMaxLength(500); + entity.Property(u => u.GoogleCalendarSelectedIds).HasColumnType("text"); + entity.HasIndex(u => new { u.GoogleCalendarAutoSyncEnabled, u.GoogleCalendarLastSyncedAt }) .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs index ca7401c5..ce022d15 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs @@ -968,7 +968,8 @@ private static AgentCapability[] CalendarCapabilities() controllerActions: [ "CalendarController.GetEvents", - "CalendarController.GetSuggestions" + "CalendarController.GetSuggestions", + "CalendarController.GetCalendars" ]), CreateCapability( @@ -990,7 +991,8 @@ private static AgentCapability[] CalendarCapabilities() "CalendarController.DismissImport", "CalendarController.SetAutoSync", "CalendarController.DismissSuggestion", - "CalendarController.RunSyncNow" + "CalendarController.RunSyncNow", + "CalendarController.SetSelectedCalendars" ]) ]; } diff --git a/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs b/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs index 54e6fdfc..61a32821 100644 --- a/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs +++ b/src/Orbit.Infrastructure/Services/AiRetrospectiveService.cs @@ -153,7 +153,7 @@ private static string BuildRetrospectivePrompt( var (habitSection, totalCompletions, totalScheduled, badHabitSlips) = BuildHabitBreakdown(habits, dateFrom, dateTo, totalDays); - var overallRate = totalScheduled > 0 ? (int)Math.Round(100.0 * totalCompletions / totalScheduled) : 0; + var overallRate = totalScheduled > 0 ? Math.Min(100, (int)Math.Round(100.0 * totalCompletions / totalScheduled)) : 0; return $""" Period: Last {totalDays} days ({period}) -- {dateFrom:MMMM d} to {dateTo:MMMM d, yyyy} @@ -213,7 +213,7 @@ private static (string HabitSection, int TotalCompletions, int TotalScheduled, i private static void AppendParentHabitLine( List lines, Habit habit, int scheduledCount, int completedCount, int totalDays, ref int badHabitSlips) { - var rate = scheduledCount > 0 ? (int)Math.Round(100.0 * completedCount / scheduledCount) : 0; + var rate = scheduledCount > 0 ? Math.Min(100, (int)Math.Round(100.0 * completedCount / scheduledCount)) : 0; if (habit.IsBadHabit) { diff --git a/src/Orbit.Infrastructure/Services/Calendar/CalendarInfrastructureServiceCollectionExtensions.cs b/src/Orbit.Infrastructure/Services/Calendar/CalendarInfrastructureServiceCollectionExtensions.cs new file mode 100644 index 00000000..1ad60254 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Calendar/CalendarInfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using Orbit.Application.Calendar.Services; + +namespace Orbit.Infrastructure.Services.Calendar; + +/// +/// Registers Infrastructure-internal Google Calendar collaborators that the composition root +/// in Orbit.Api cannot reference directly (the fetcher and its SDK seam are intentionally +/// internal). Keeps the vendor SDK wrapper out of Application's view while still wiring it for DI. +/// +public static class CalendarInfrastructureServiceCollectionExtensions +{ + public static IServiceCollection AddGoogleCalendarServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/Orbit.Infrastructure/Services/Calendar/GoogleCalendarApi.cs b/src/Orbit.Infrastructure/Services/Calendar/GoogleCalendarApi.cs new file mode 100644 index 00000000..5797db3c --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Calendar/GoogleCalendarApi.cs @@ -0,0 +1,93 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Calendar.v3; +using Google.Apis.Calendar.v3.Data; +using Google.Apis.Services; + +namespace Orbit.Infrastructure.Services.Calendar; + +/// +/// Production backed by the Google Calendar v3 SDK. Owns +/// SDK construction (so Application never sees vendor types) and drains every list endpoint's +/// pagination via its page-token loop. Kept deliberately logic-free: filtering, dedup, and +/// mapping live in so they stay unit-testable. +/// +internal sealed class GoogleCalendarApi : IGoogleCalendarApi +{ + private const int EventsPageSize = 2500; + + public async Task> ListCalendarsAsync(string accessToken, CancellationToken ct) + { + using var service = CreateCalendarService(accessToken); + + var entries = new List(); + string? pageToken = null; + do + { + var request = service.CalendarList.List(); + request.PageToken = pageToken; + var response = await request.ExecuteAsync(ct); + if (response.Items is { Count: > 0 }) + entries.AddRange(response.Items); + pageToken = response.NextPageToken; + } + while (!string.IsNullOrEmpty(pageToken)); + + return entries; + } + + public async Task> ListEventsAsync( + string accessToken, string calendarId, DateTime? updatedMin, CancellationToken ct) + { + using var service = CreateCalendarService(accessToken); + + var events = new List(); + string? pageToken = null; + do + { + var request = BuildEventsRequest(service, calendarId, updatedMin); + request.PageToken = pageToken; + var response = await request.ExecuteAsync(ct); + if (response.Items is { Count: > 0 }) + events.AddRange(response.Items); + pageToken = response.NextPageToken; + } + while (!string.IsNullOrEmpty(pageToken)); + + return events; + } + + public async Task GetEventAsync(string accessToken, string calendarId, string eventId, CancellationToken ct) + { + using var service = CreateCalendarService(accessToken); + return await service.Events.Get(calendarId, eventId).ExecuteAsync(ct); + } + + private static EventsResource.ListRequest BuildEventsRequest( + CalendarService service, string calendarId, DateTime? updatedMin) + { + var request = service.Events.List(calendarId); + request.SingleEvents = true; + request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow; + request.TimeMaxDateTimeOffset = DateTimeOffset.UtcNow.AddDays(60); + request.MaxResults = EventsPageSize; + request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime; + + if (updatedMin.HasValue) + { + request.UpdatedMinDateTimeOffset = new DateTimeOffset( + DateTime.SpecifyKind(updatedMin.Value, DateTimeKind.Utc)); + } + + return request; + } + + private static CalendarService CreateCalendarService(string accessToken) + { + var credential = GoogleCredential.FromAccessToken(accessToken); + return new CalendarService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Orbit" + }); + } +} diff --git a/src/Orbit.Infrastructure/Services/Calendar/IGoogleCalendarApi.cs b/src/Orbit.Infrastructure/Services/Calendar/IGoogleCalendarApi.cs new file mode 100644 index 00000000..9bf4e4b5 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Calendar/IGoogleCalendarApi.cs @@ -0,0 +1,27 @@ +using Google.Apis.Calendar.v3.Data; + +namespace Orbit.Infrastructure.Services.Calendar; + +/// +/// Thin testable seam over the Google Calendar SDK. Production wraps the real +/// CalendarService; tests substitute it to exercise the owned-calendar filter, +/// per-calendar aggregation, pagination, and dedup logic in +/// without the vendor SDK. Each method already drains the provider's pagination so callers +/// receive the full result set. +/// +internal interface IGoogleCalendarApi +{ + /// Lists every on the user's calendar list, following page tokens. + Task> ListCalendarsAsync(string accessToken, CancellationToken ct); + + /// + /// Lists events for a single calendar with the fixed forward window (SingleEvents, TimeMin=now, + /// TimeMax=now+60d, OrderBy=StartTime), following page tokens. narrows + /// the result to events changed after that UTC instant when provided. + /// + Task> ListEventsAsync( + string accessToken, string calendarId, DateTime? updatedMin, CancellationToken ct); + + /// Fetches a single event (used to resolve a recurring master's RRULE). + Task GetEventAsync(string accessToken, string calendarId, string eventId, CancellationToken ct); +} diff --git a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs index 4dcfa48b..ee268ecd 100644 --- a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs +++ b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs @@ -1,31 +1,62 @@ -using Google.Apis.Auth.OAuth2; -using Google.Apis.Calendar.v3; -using Google.Apis.Services; +using Google.Apis.Calendar.v3.Data; using Microsoft.Extensions.Logging; using Orbit.Application.Calendar.Queries; using Orbit.Application.Calendar.Services; using Orbit.Application.Common; +using Orbit.Infrastructure.Services.Calendar; namespace Orbit.Infrastructure.Services; /// -/// Google-Calendar-backed implementation of . Owns -/// construction of the Google SDK CalendarService so Application never sees the vendor -/// SDK types (Clean Architecture: vendor integrations belong in Infrastructure). +/// Google-Calendar-backed implementation of . Fans the +/// fetch out across the user's owned (or explicitly selected) calendars, merging the results +/// and tagging each event with its source calendar. Vendor SDK construction is delegated to +/// so this aggregation/filter/dedup logic stays unit-testable +/// (Clean Architecture: vendor integrations belong in Infrastructure). /// -public partial class GoogleCalendarEventFetcher(ILogger logger) : ICalendarEventFetcher +internal sealed partial class GoogleCalendarEventFetcher( + IGoogleCalendarApi api, + ILogger logger) : ICalendarEventFetcher { public async Task> FetchAsync( string accessToken, + IReadOnlyCollection? selectedCalendarIds, DateTime? updatedMin, CancellationToken ct) { - using var service = CreateCalendarService(accessToken); + var calendars = await ListCalendarEntries(accessToken, ct); + var targets = SelectTargetCalendars(calendars, selectedCalendarIds); - Google.Apis.Calendar.v3.Data.Events events; + var items = new List(); + foreach (var calendar in targets) + { + ct.ThrowIfCancellationRequested(); + items.AddRange(await FetchCalendarEvents(accessToken, calendar, updatedMin, ct)); + } + + return items; + } + + public async Task> ListCalendarsAsync(string accessToken, CancellationToken ct) + { + var calendars = await ListCalendarEntries(accessToken, ct); + return calendars + .Where(c => c.Deleted != true && c.Hidden != true) + .Select(c => new CalendarListItem( + c.Id, + ResolveCalendarName(c), + c.AccessRole ?? string.Empty, + c.Primary == true, + c.BackgroundColor, + IsDefaultOwned(c))) + .ToList(); + } + + private async Task> ListCalendarEntries(string accessToken, CancellationToken ct) + { try { - events = await ExecuteList(service, updatedMin, ct); + return await api.ListCalendarsAsync(accessToken, ct); } catch (Google.GoogleApiException ex) { @@ -36,76 +67,85 @@ public async Task> FetchAsync( $"Google Calendar API error: {rawCode}", ex); } + } - var items = new List(); - var seenRecurringMasterIds = new HashSet(); - var masterRRuleCache = new Dictionary(); - - foreach (var ev in events.Items ?? []) - { - if (string.IsNullOrWhiteSpace(ev.Summary)) continue; - - if (string.Equals(ev.Status, "cancelled", StringComparison.OrdinalIgnoreCase)) - continue; - - var masterId = ev.RecurringEventId ?? ev.Id; - if (ev.RecurringEventId is not null && !seenRecurringMasterIds.Add(ev.RecurringEventId)) - continue; - - var evTitle = ev.Summary.Trim(); - var startDate = ev.Start?.Date ?? ev.Start?.DateTimeDateTimeOffset?.ToString("yyyy-MM-dd"); - var startTime = ev.Start?.DateTimeDateTimeOffset?.ToString("HH:mm"); - var endTime = ev.End?.DateTimeDateTimeOffset?.ToString("HH:mm"); - var startUtc = ResolveStartUtc(ev.Start); - var isRecurring = ev.RecurringEventId is not null - || (ev.Recurrence is not null && ev.Recurrence.Count > 0); - - var rrule = await ResolveRRule(ev, service, masterRRuleCache, ct); - var reminders = BuildReminders(ev, startTime); + private static List SelectTargetCalendars( + IReadOnlyList calendars, IReadOnlyCollection? selectedCalendarIds) + { + var accessible = calendars.Where(c => c.Deleted != true && c.Hidden != true); - items.Add(new CalendarEventItem( - masterId, evTitle, ev.Description, - startDate, startTime, endTime, - isRecurring, rrule, reminders, startUtc)); - } + if (selectedCalendarIds is null) + return accessible.Where(IsDefaultOwned).ToList(); - return items; + var selected = new HashSet(selectedCalendarIds, StringComparer.Ordinal); + return accessible.Where(c => selected.Contains(c.Id)).ToList(); } - private static CalendarService CreateCalendarService(string accessToken) + private async Task> FetchCalendarEvents( + string accessToken, CalendarListEntry calendar, DateTime? updatedMin, CancellationToken ct) { - var credential = GoogleCredential.FromAccessToken(accessToken); - return new CalendarService(new BaseClientService.Initializer + var calendarName = ResolveCalendarName(calendar); + IReadOnlyList events; + try + { + events = await api.ListEventsAsync(accessToken, calendar.Id, updatedMin, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) { - HttpClientInitializer = credential, - ApplicationName = "Orbit" - }); + LogCalendarFetchSkipped(logger, ex, calendar.Id); + return []; + } + + return await MapCalendarEvents(accessToken, calendar.Id, calendarName, events, ct); } - private static async Task ExecuteList( - CalendarService service, DateTime? updatedMin, CancellationToken ct) + private async Task> MapCalendarEvents( + string accessToken, string calendarId, string calendarName, IReadOnlyList events, CancellationToken ct) { - var listRequest = service.Events.List("primary"); - listRequest.SingleEvents = true; - listRequest.TimeMinDateTimeOffset = DateTimeOffset.UtcNow; - listRequest.TimeMaxDateTimeOffset = DateTimeOffset.UtcNow.AddDays(60); - listRequest.MaxResults = 250; - listRequest.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime; - - if (updatedMin.HasValue) + var items = new List(); + var seenRecurringMasterIds = new HashSet(StringComparer.Ordinal); + var masterRRuleCache = new Dictionary(StringComparer.Ordinal); + + foreach (var ev in events) { - listRequest.UpdatedMinDateTimeOffset = new DateTimeOffset( - DateTime.SpecifyKind(updatedMin.Value, DateTimeKind.Utc)); + if (string.IsNullOrWhiteSpace(ev.Summary)) continue; + if (string.Equals(ev.Status, "cancelled", StringComparison.OrdinalIgnoreCase)) continue; + if (ev.RecurringEventId is not null && !seenRecurringMasterIds.Add(ev.RecurringEventId)) continue; + + var rrule = await ResolveRRule(accessToken, calendarId, ev, masterRRuleCache, ct); + items.Add(MapEvent(ev, calendarId, calendarName, rrule)); } - return await listRequest.ExecuteAsync(ct); + return items; + } + + private static CalendarEventItem MapEvent(Event ev, string calendarId, string calendarName, string? rrule) + { + var startTime = ev.Start?.DateTimeDateTimeOffset?.ToString("HH:mm"); + var isRecurring = ev.RecurringEventId is not null + || (ev.Recurrence is not null && ev.Recurrence.Count > 0); + + return new CalendarEventItem( + ev.RecurringEventId ?? ev.Id, + ev.Summary.Trim(), + ev.Description, + ev.Start?.Date ?? ev.Start?.DateTimeDateTimeOffset?.ToString("yyyy-MM-dd"), + startTime, + ev.End?.DateTimeDateTimeOffset?.ToString("HH:mm"), + isRecurring, + rrule, + BuildReminders(ev, startTime), + ResolveStartUtc(ev.Start), + calendarId, + calendarName); } private async Task ResolveRRule( - Google.Apis.Calendar.v3.Data.Event ev, - CalendarService service, + string accessToken, + string calendarId, + Event ev, Dictionary masterRRuleCache, - CancellationToken cancellationToken) + CancellationToken ct) { if (ev.Recurrence is not null) return ev.Recurrence.FirstOrDefault(r => r.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase)); @@ -119,7 +159,7 @@ private static CalendarService CreateCalendarService(string accessToken) string? rrule; try { - var master = await service.Events.Get("primary", ev.RecurringEventId).ExecuteAsync(cancellationToken); + var master = await api.GetEventAsync(accessToken, calendarId, ev.RecurringEventId, ct); rrule = master.Recurrence?.FirstOrDefault(r => r.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase)); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -132,8 +172,7 @@ private static CalendarService CreateCalendarService(string accessToken) return rrule; } - internal static List BuildReminders( - Google.Apis.Calendar.v3.Data.Event ev, string? startTime) + internal static List BuildReminders(Event ev, string? startTime) { var reminders = ev.Reminders?.Overrides? .Where(r => r.Minutes.HasValue) @@ -153,7 +192,15 @@ internal static List BuildReminders( return reminders; } - private static DateTime? ResolveStartUtc(Google.Apis.Calendar.v3.Data.EventDateTime? start) + private static bool IsDefaultOwned(CalendarListEntry entry) => + string.Equals(entry.AccessRole, "owner", StringComparison.OrdinalIgnoreCase) + && entry.Deleted != true + && entry.Hidden != true; + + private static string ResolveCalendarName(CalendarListEntry entry) => + entry.SummaryOverride ?? entry.Summary ?? string.Empty; + + private static DateTime? ResolveStartUtc(EventDateTime? start) { if (start is null) return null; @@ -198,4 +245,7 @@ private static string NormalizeGoogleApiErrorCode(Google.GoogleApiException ex) [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Failed to fetch master event RRULE for recurring event {EventId}")] private static partial void LogFetchMasterRruleFailed(ILogger logger, Exception ex, string? eventId); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Skipped Google Calendar {CalendarId} after a fetch error")] + private static partial void LogCalendarFetchSkipped(ILogger logger, Exception ex, string calendarId); } diff --git a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs index 193b2c8d..3094f4ff 100644 --- a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs @@ -144,7 +144,7 @@ public async Task Handle_Success_CreatesSuggestionsForNewEvents() _tokenService.TryRefreshAsync(user, Arg.Any()) .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), @@ -176,7 +176,7 @@ public async Task Handle_Success_DedupesAgainstExistingHabits() _habitRepo.FindAsync(Arg.Any>>(), Arg.Any()) .Returns(new List { existingHabit }.AsReadOnly()); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), @@ -199,7 +199,7 @@ public async Task Handle_Success_CapsAtMaxSuggestions() var many = Enumerable.Range(0, 40) .Select(i => new CalendarEventItem($"evt_{i}", $"Event {i}", null, "2026-04-10", null, null, false, null, [])) .ToList(); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(many); var result = await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); @@ -217,7 +217,7 @@ public async Task Handle_Success_QuietHours_DoesNotCreateNotification() _tokenService.TryRefreshAsync(user, Arg.Any()) .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Event", null, "2026-04-10", null, null, false, null, []) @@ -238,7 +238,7 @@ public async Task Handle_Success_RateLimit_DoesNotCreateNotificationIfRecentExis _notificationRepo.AnyAsync(Arg.Any>>(), Arg.Any()) .Returns(true); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Event", null, "2026-04-10", null, null, false, null, []) @@ -266,7 +266,7 @@ public async Task Handle_Success_ReconciliationPass_BackfillsGoogleEventIdOnExis _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) .Returns(new List { orphanHabit }.AsReadOnly()); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []) @@ -297,7 +297,7 @@ public async Task Handle_Success_ReconciliationRunsAgainForHabitsStillMissingGoo _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) .Returns(new List { orphanHabit }.AsReadOnly()); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_review", "Imported Review Event", null, "2026-04-10", "09:00", "09:30", true, null, []) @@ -329,7 +329,7 @@ public async Task Handle_Success_ReconciliationSkipsAmbiguousHabitMatches() _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) .Returns(new List { firstHabit, secondHabit }.AsReadOnly()); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []) @@ -374,7 +374,7 @@ public async Task Handle_Success_ReconciliationSkipsAlreadyAssignedGoogleEventId return allHabits.Where(predicate).ToList().AsReadOnly(); }); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []) @@ -406,7 +406,7 @@ public async Task Handle_Success_ReconciliationSkipsDuplicateFetchedEventIdsAcro _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) .Returns(new List { firstHabit, secondHabit }.AsReadOnly()); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_dup", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), @@ -428,7 +428,7 @@ public async Task Handle_Success_CreateSuggestionsSkipsDuplicateFetchedEventIds( _tokenService.TryRefreshAsync(user, Arg.Any()) .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_dup", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), @@ -452,7 +452,7 @@ public async Task Handle_Success_PersistsRealUtcInstantForOffsetEvent() .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); var realUtc = new DateTime(2026, 4, 11, 2, 30, 0, DateTimeKind.Utc); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_a", "Late event", null, "2026-04-10", "23:30", "23:45", false, null, [], realUtc) @@ -478,7 +478,7 @@ public async Task Handle_Success_FallsBackToStartDateWhenNoUtcInstant() _tokenService.TryRefreshAsync(user, Arg.Any()) .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_allday", "All day", null, "2026-04-12", null, null, false, null, []) @@ -521,7 +521,7 @@ public async Task Handle_FetchReconnectRequired_RoutesToReconnectRequired() StubUser(user); _tokenService.TryRefreshAsync(user, Arg.Any()) .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns>>(_ => throw new CalendarProviderException( CalendarFetchErrorKind.ReconnectRequired, "invalid_grant", "boom", new Exception())); @@ -543,7 +543,7 @@ public async Task Handle_FetchTransientError_MarksTransientAndDoesNotDisconnect( StubUser(user); _tokenService.TryRefreshAsync(user, Arg.Any()) .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns>>(_ => throw new CalendarProviderException( CalendarFetchErrorKind.Transient, "rate_limit", "boom", new Exception())); @@ -565,7 +565,7 @@ public async Task Handle_OpportunisticSkipsDedupe() StubUser(user); _tokenService.TryRefreshAsync(user, Arg.Any()) .Returns(new GoogleTokenRefreshOutcome("new", GoogleTokenRefreshResult.Success, null)); - _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List()); var result = await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id, IsOpportunistic: true), default); diff --git a/tests/Orbit.Application.Tests/Commands/Calendar/SetSelectedCalendarsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Calendar/SetSelectedCalendarsCommandHandlerTests.cs new file mode 100644 index 00000000..cb484d32 --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Calendar/SetSelectedCalendarsCommandHandlerTests.cs @@ -0,0 +1,105 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Calendar.Commands; +using Orbit.Application.Calendar.Validators; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Commands.Calendar; + +public class SetSelectedCalendarsCommandHandlerTests +{ + private readonly IGenericRepository _userRepo = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly SetSelectedCalendarsCommandHandler _handler; + + public SetSelectedCalendarsCommandHandlerTests() + { + _handler = new SetSelectedCalendarsCommandHandler(_userRepo, _unitOfWork); + } + + private static User CreateUser() => User.Create("Test", "test@example.com").Value; + + private void StubUser(User? user) => + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + + [Fact] + public async Task Handle_UserNotFound_ReturnsFailure() + { + StubUser(null); + + var result = await _handler.Handle( + new SetSelectedCalendarsCommand(Guid.NewGuid(), new[] { "cal_a" }), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_SetsSelectionAndPersists() + { + var user = CreateUser(); + StubUser(user); + + var result = await _handler.Handle( + new SetSelectedCalendarsCommand(user.Id, new[] { "cal_a", "cal_b" }), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.GetSelectedCalendarIds().Should().BeEquivalentTo(new[] { "cal_a", "cal_b" }); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_EmptyList_ClearsSelectionToDefault() + { + var user = CreateUser(); + user.SetSelectedCalendars(new[] { "cal_a" }); + StubUser(user); + + var result = await _handler.Handle( + new SetSelectedCalendarsCommand(user.Id, Array.Empty()), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.GoogleCalendarSelectedIds.Should().BeNull(); + user.GetSelectedCalendarIds().Should().BeNull(); + } + + [Fact] + public void Validator_RejectsEmptyId() + { + var validator = new SetSelectedCalendarsCommandValidator(); + + var result = validator.Validate( + new SetSelectedCalendarsCommand(Guid.NewGuid(), new[] { "cal_a", "" })); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void Validator_RejectsOversizedList() + { + var validator = new SetSelectedCalendarsCommandValidator(); + var tooMany = Enumerable.Range(0, 51).Select(i => $"cal_{i}").ToArray(); + + var result = validator.Validate( + new SetSelectedCalendarsCommand(Guid.NewGuid(), tooMany)); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void Validator_AcceptsValidSelection() + { + var validator = new SetSelectedCalendarsCommandValidator(); + + var result = validator.Validate( + new SetSelectedCalendarsCommand(Guid.NewGuid(), new[] { "cal_a", "cal_b" })); + + result.IsValid.Should().BeTrue(); + } +} diff --git a/tests/Orbit.Application.Tests/Common/TimeFormatResolverTests.cs b/tests/Orbit.Application.Tests/Common/TimeFormatResolverTests.cs new file mode 100644 index 00000000..fd04031c --- /dev/null +++ b/tests/Orbit.Application.Tests/Common/TimeFormatResolverTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Orbit.Application.Common; + +namespace Orbit.Application.Tests.Common; + +public class TimeFormatResolverTests +{ + [Theory] + [InlineData("America/Sao_Paulo")] + [InlineData("Europe/Paris")] + [InlineData("Europe/London")] + [InlineData("Asia/Tokyo")] + [InlineData("America/Argentina/Buenos_Aires")] + public void Uses24HourClock_ReturnsTrue_For24HourRegions(string timeZone) + { + TimeFormatResolver.Uses24HourClock(timeZone).Should().BeTrue(); + } + + [Theory] + [InlineData("America/New_York")] + [InlineData("America/Los_Angeles")] + [InlineData("America/Chicago")] + [InlineData("America/Toronto")] + [InlineData("Australia/Sydney")] + [InlineData("Pacific/Auckland")] + [InlineData("Asia/Kolkata")] + [InlineData("Asia/Manila")] + public void Uses24HourClock_ReturnsFalse_For12HourRegions(string timeZone) + { + TimeFormatResolver.Uses24HourClock(timeZone).Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Not/AZone")] + public void Uses24HourClock_DefaultsToTrue_ForNullOrUnknown(string? timeZone) + { + TimeFormatResolver.Uses24HourClock(timeZone).Should().BeTrue(); + } +} diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 123e2cc7..736ead7a 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -109,7 +109,7 @@ public async Task Handle_ValidToken_PersistsRefreshedToken() Arg.Any>>(), Arg.Any()) .Returns(new List().AsReadOnly()); - _eventFetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _eventFetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List()); var query = new GetCalendarEventsQuery(UserId); @@ -165,7 +165,7 @@ public async Task Handle_FiltersOutAlreadyImportedHabitsByGoogleEventId() Arg.Any()) .Returns(new List().AsReadOnly()); - _eventFetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _eventFetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { new("evt_already", "Existing", null, "2026-05-01", null, null, true, null, []), @@ -219,6 +219,7 @@ public async Task Handle_GoogleApiAuthenticationError_MarksReconnectRequiredAndR .Returns(new List().AsReadOnly()); _eventFetcher.FetchAsync( Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns>>(_ => throw new CalendarProviderException( diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetUserCalendarsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetUserCalendarsQueryHandlerTests.cs new file mode 100644 index 00000000..217356e2 --- /dev/null +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetUserCalendarsQueryHandlerTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Orbit.Application.Calendar.Queries; +using Orbit.Application.Calendar.Services; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Queries.Calendar; + +public class GetUserCalendarsQueryHandlerTests +{ + private readonly IGenericRepository _userRepo = Substitute.For>(); + private readonly IPayGateService _payGate = Substitute.For(); + private readonly IGoogleTokenService _googleTokenService = Substitute.For(); + private readonly ICalendarEventFetcher _eventFetcher = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + private readonly GetUserCalendarsQueryHandler _handler; + + private static readonly Guid UserId = Guid.NewGuid(); + + public GetUserCalendarsQueryHandlerTests() + { + _payGate.CanAccessCalendar(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success())); + _handler = new GetUserCalendarsQueryHandler( + _userRepo, _payGate, _googleTokenService, _eventFetcher, _unitOfWork, _logger); + } + + private static User CreateUser() => User.Create("Test", "test@example.com").Value; + + private static List SampleCalendars() => + [ + new("owned", "Rotina", "owner", true, "#fff", IsDefaultOwned: true), + new("shared", "Team", "reader", false, "#abc", IsDefaultOwned: false) + ]; + + [Fact] + public async Task Handle_UserNotFound_ReturnsFailure() + { + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns((User?)null); + + var result = await _handler.Handle(new GetUserCalendarsQuery(UserId), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } + + [Fact] + public async Task Handle_NotConnected_ReturnsCalendarNotConnected() + { + var user = CreateUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _googleTokenService.GetValidAccessTokenAsync(user, Arg.Any()).Returns((string?)null); + + var result = await _handler.Handle(new GetUserCalendarsQuery(UserId), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.CalendarNotConnected); + } + + [Fact] + public async Task Handle_NullSelection_IsSyncedReflectsDefaultOwned() + { + var user = CreateUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _googleTokenService.GetValidAccessTokenAsync(user, Arg.Any()).Returns("token"); + _eventFetcher.ListCalendarsAsync("token", Arg.Any()).Returns(SampleCalendars()); + + var result = await _handler.Handle(new GetUserCalendarsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Single(c => c.Id == "owned").IsSynced.Should().BeTrue(); + result.Value.Single(c => c.Id == "shared").IsSynced.Should().BeFalse(); + } + + [Fact] + public async Task Handle_ExplicitSelection_IsSyncedReflectsSelectedSet() + { + var user = CreateUser(); + user.SetSelectedCalendars(new[] { "shared" }); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _googleTokenService.GetValidAccessTokenAsync(user, Arg.Any()).Returns("token"); + _eventFetcher.ListCalendarsAsync("token", Arg.Any()).Returns(SampleCalendars()); + + var result = await _handler.Handle(new GetUserCalendarsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Single(c => c.Id == "owned").IsSynced.Should().BeFalse(); + result.Value.Single(c => c.Id == "shared").IsSynced.Should().BeTrue(); + } + + [Fact] + public async Task Handle_MapsAllReturnedFields() + { + var user = CreateUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _googleTokenService.GetValidAccessTokenAsync(user, Arg.Any()).Returns("token"); + _eventFetcher.ListCalendarsAsync("token", Arg.Any()).Returns(SampleCalendars()); + + var result = await _handler.Handle(new GetUserCalendarsQuery(UserId), CancellationToken.None); + + var owned = result.Value.Single(c => c.Id == "owned"); + owned.Name.Should().Be("Rotina"); + owned.AccessRole.Should().Be("owner"); + owned.Primary.Should().BeTrue(); + owned.BackgroundColor.Should().Be("#fff"); + } + + [Fact] + public async Task Handle_ReconnectRequired_MarksUserAndReturnsReconnectMessage() + { + var user = CreateUser(); + user.SetGoogleTokens("stale", null); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _googleTokenService.GetValidAccessTokenAsync(user, Arg.Any()).Returns("stale"); + _eventFetcher.ListCalendarsAsync("stale", Arg.Any()) + .Returns>>(_ => throw new CalendarProviderException( + CalendarFetchErrorKind.ReconnectRequired, "invalid_grant", "boom", new Exception())); + + var result = await _handler.Handle(new GetUserCalendarsQuery(UserId), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.CalendarReconnectRequired); + user.GoogleAccessToken.Should().BeNull(); + } +} diff --git a/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs index fedf943c..a847e8e9 100644 --- a/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Habits/GetRetrospectiveQueryHandlerTests.cs @@ -295,4 +295,21 @@ public async Task Handle_HabitsButNoCompletions_ReturnsFailure() result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("No habits found"); } + + [Fact] + public async Task Handle_FlagsOneTimeTasks_AsBinary() + { + var recurring = CreateLoggedHabit("Recurring"); + var oneTime = Habit.Create(new HabitCreateParams( + UserId, "One Time", null, null, DueDate: DateFrom)).Value; + StubHabits(recurring, oneTime); + StubNarrative(SampleNarrative); + + var result = await HandleWeek(); + + var needs = result.Value.Metrics.NeedsAttention; + needs.Single(s => s.Name == "One Time").IsOneTime.Should().BeTrue(); + needs.Single(s => s.Name == "One Time").CompletedCount.Should().Be(0); + needs.Single(s => s.Name == "Recurring").IsOneTime.Should().BeFalse(); + } } diff --git a/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs index f5b28268..4f33ca42 100644 --- a/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs @@ -173,4 +173,38 @@ public async Task Handle_AllFreezesUsed_ReturnsZeroAvailable() result.IsSuccess.Should().BeTrue(); result.Value.StreakFreezesAvailable.Should().Be(0); } + + [Fact] + public async Task Handle_Returns24HourClock_ForBrazilTimeZone() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo"); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + _streakFreezeRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List().AsReadOnly()); + + var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); + + result.Value.Uses24HourClock.Should().BeTrue(); + } + + [Fact] + public async Task Handle_Returns12HourClock_ForUnitedStatesTimeZone() + { + var user = CreateTestUser(); + user.SetTimeZone("America/New_York"); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + _streakFreezeRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List().AsReadOnly()); + + var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); + + result.Value.Uses24HourClock.Should().BeFalse(); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs new file mode 100644 index 00000000..045eb1d8 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs @@ -0,0 +1,215 @@ +using FluentAssertions; +using Google.Apis.Calendar.v3.Data; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Orbit.Application.Calendar.Services; +using Orbit.Infrastructure.Services; +using Orbit.Infrastructure.Services.Calendar; + +namespace Orbit.Infrastructure.Tests.Services; + +public class GoogleCalendarEventFetcherTests +{ + private readonly IGoogleCalendarApi _api = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + private readonly GoogleCalendarEventFetcher _fetcher; + + private const string Token = "access-token"; + + public GoogleCalendarEventFetcherTests() + { + _fetcher = new GoogleCalendarEventFetcher(_api, _logger); + _api.ListEventsAsync(Token, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List()); + } + + private static CalendarListEntry Calendar( + string id, string accessRole, string? summary = null, + bool? deleted = null, bool? hidden = null, bool? primary = null, + string? summaryOverride = null, string? backgroundColor = null) + => new() + { + Id = id, + AccessRole = accessRole, + Summary = summary ?? id, + SummaryOverride = summaryOverride, + Deleted = deleted, + Hidden = hidden, + Primary = primary, + BackgroundColor = backgroundColor + }; + + private static Event TimedEvent(string id, string summary, string? recurringEventId = null) + => new() + { + Id = id, + Summary = summary, + RecurringEventId = recurringEventId, + Start = new EventDateTime { DateTimeDateTimeOffset = new DateTimeOffset(2026, 5, 1, 9, 0, 0, TimeSpan.Zero) }, + End = new EventDateTime { DateTimeDateTimeOffset = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero) } + }; + + private void StubCalendars(params CalendarListEntry[] calendars) + => _api.ListCalendarsAsync(Token, Arg.Any()) + .Returns(calendars.ToList()); + + private void StubEvents(string calendarId, params Event[] events) + => _api.ListEventsAsync(Token, calendarId, Arg.Any(), Arg.Any()) + .Returns(events.ToList()); + + [Fact] + public async Task FetchAsync_NullSelection_IncludesOwnedExcludesReader() + { + StubCalendars( + Calendar("owned", "owner", summary: "Rotina"), + Calendar("holidays", "reader", summary: "Holidays")); + StubEvents("owned", TimedEvent("e1", "Workout")); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().HaveCount(1); + result[0].CalendarId.Should().Be("owned"); + result[0].CalendarName.Should().Be("Rotina"); + await _api.DidNotReceive().ListEventsAsync(Token, "holidays", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task FetchAsync_NullSelection_ExcludesDeletedAndHiddenOwned() + { + StubCalendars( + Calendar("deleted", "owner", deleted: true), + Calendar("hidden", "owner", hidden: true), + Calendar("live", "owner")); + StubEvents("live", TimedEvent("e1", "Live event")); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].CalendarId.Should().Be("live"); + await _api.DidNotReceive().ListEventsAsync(Token, "deleted", Arg.Any(), Arg.Any()); + await _api.DidNotReceive().ListEventsAsync(Token, "hidden", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task FetchAsync_MergesEventsAcrossOwnedCalendarsWithTagging() + { + StubCalendars( + Calendar("a", "owner", summary: "Calendar A"), + Calendar("b", "owner", summary: "B raw", summaryOverride: "B Override")); + StubEvents("a", TimedEvent("a1", "From A")); + StubEvents("b", TimedEvent("b1", "From B")); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().HaveCount(2); + result.Should().ContainSingle(i => i.CalendarId == "a" && i.CalendarName == "Calendar A"); + result.Should().ContainSingle(i => i.CalendarId == "b" && i.CalendarName == "B Override"); + } + + [Fact] + public async Task FetchAsync_ExplicitSelection_FetchesOnlyChosenCalendars() + { + StubCalendars( + Calendar("a", "owner"), + Calendar("b", "owner"), + Calendar("shared", "reader")); + StubEvents("shared", TimedEvent("s1", "Shared event")); + + var result = await _fetcher.FetchAsync(Token, new[] { "shared" }, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].CalendarId.Should().Be("shared"); + await _api.Received(1).ListEventsAsync(Token, "shared", Arg.Any(), Arg.Any()); + await _api.DidNotReceive().ListEventsAsync(Token, "a", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task FetchAsync_ExplicitSelection_StillSkipsDeletedAndHidden() + { + StubCalendars(Calendar("chosen", "owner", deleted: true)); + + var result = await _fetcher.FetchAsync(Token, new[] { "chosen" }, null, CancellationToken.None); + + result.Should().BeEmpty(); + await _api.DidNotReceive().ListEventsAsync(Token, "chosen", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task FetchAsync_RecurringMasterDedup_IsPerCalendar() + { + StubCalendars(Calendar("a", "owner"), Calendar("b", "owner")); + StubEvents("a", + TimedEvent("a-inst-1", "Standup", recurringEventId: "master"), + TimedEvent("a-inst-2", "Standup", recurringEventId: "master")); + StubEvents("b", TimedEvent("b-inst-1", "Standup", recurringEventId: "master")); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().HaveCount(2); + result.Should().OnlyContain(i => i.Id == "master"); + result.Select(i => i.CalendarId).Should().BeEquivalentTo(new[] { "a", "b" }); + } + + [Fact] + public async Task FetchAsync_FailingCalendar_IsSkippedNotFatal() + { + StubCalendars(Calendar("good", "owner"), Calendar("bad", "owner")); + StubEvents("good", TimedEvent("g1", "Good event")); + _api.ListEventsAsync(Token, "bad", Arg.Any(), Arg.Any()) + .Returns>>(_ => throw new InvalidOperationException("boom")); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].CalendarId.Should().Be("good"); + } + + [Fact] + public async Task FetchAsync_SkipsCancelledAndUntitledEvents() + { + StubCalendars(Calendar("a", "owner")); + var cancelled = TimedEvent("c1", "Cancelled"); + cancelled.Status = "cancelled"; + var untitled = TimedEvent("u1", " "); + StubEvents("a", cancelled, untitled, TimedEvent("ok", "Kept")); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].Id.Should().Be("ok"); + } + + [Fact] + public async Task FetchAsync_ListCalendarsAuthError_ThrowsReconnectRequired() + { + _api.ListCalendarsAsync(Token, Arg.Any()) + .Returns>>(_ => throw new Google.GoogleApiException("calendar", "insufficient authentication scopes") + { + HttpStatusCode = System.Net.HttpStatusCode.Forbidden + }); + + var act = async () => await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Kind.Should().Be(CalendarFetchErrorKind.ReconnectRequired); + } + + [Fact] + public async Task ListCalendarsAsync_MapsEntriesAndComputesDefaultOwned() + { + StubCalendars( + Calendar("owned", "owner", summary: "Rotina", primary: true, backgroundColor: "#fff"), + Calendar("shared", "reader", summary: "Team"), + Calendar("hiddenOwned", "owner", hidden: true)); + + var result = await _fetcher.ListCalendarsAsync(Token, CancellationToken.None); + + result.Should().HaveCount(2); + var owned = result.Single(c => c.Id == "owned"); + owned.Name.Should().Be("Rotina"); + owned.Primary.Should().BeTrue(); + owned.BackgroundColor.Should().Be("#fff"); + owned.IsDefaultOwned.Should().BeTrue(); + result.Single(c => c.Id == "shared").IsDefaultOwned.Should().BeFalse(); + } +}