From 5103aecacf98b0e982ef23a72298eabd3ae40649 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:47:48 +0200 Subject: [PATCH] Add Google Calendar integration with direct sync functionality and rate limiting protection (#45) * Initial plan * Implement Google Calendar integration with sync button and calendar selection Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> * Add Google Calendar sync documentation and finalize implementation Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> * Fix build errors * Fix Google Calendar rate limiting issues with retry logic and delays Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> * optimize --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> Co-authored-by: Claudio Schaad --- Client/Pages/Index.razor | 57 ++++++- Client/Pages/Index.razor.cs | 101 +++++++++++ README.md | 10 +- Server/Controllers/ShiftController.cs | 39 ++++- Server/Program.cs | 3 + Services/ConfigurationService.cs | 8 +- Services/GoogleCalendarService.cs | 215 ++++++++++++++++++++++++ Services/IcsExportService.cs | 4 +- Services/ShiftScheduler.Services.csproj | 3 + Shared/GoogleCalendar.cs | 7 + 10 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 Services/GoogleCalendarService.cs create mode 100644 Shared/GoogleCalendar.cs diff --git a/Client/Pages/Index.razor b/Client/Pages/Index.razor index 5c1aee9..503a72e 100644 --- a/Client/Pages/Index.razor +++ b/Client/Pages/Index.razor @@ -7,9 +7,10 @@
- - - + + + +
@@ -31,6 +32,47 @@ } +@if (_showCalendarSelector) +{ + +} + @if (_isLoadingInitial) {
@@ -40,6 +82,15 @@
} +else if (_isSyncing) +{ +
+
+
⏳
+

Syncing with google calendar...

+
+
+} else { diff --git a/Client/Pages/Index.razor.cs b/Client/Pages/Index.razor.cs index 55c70ec..e480900 100644 --- a/Client/Pages/Index.razor.cs +++ b/Client/Pages/Index.razor.cs @@ -19,8 +19,11 @@ namespace ShiftScheduler.Client.Pages private bool _isLoadingTransport = false; private bool _isLoadingInitial = false; private bool _showConfigDialog = false; + private bool _isSyncing = false; + private bool _showCalendarSelector = false; private string _errorMessage = string.Empty; private string _successMessage = string.Empty; + private List _availableCalendars = new(); private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next(); @@ -162,6 +165,104 @@ namespace ShiftScheduler.Client.Pages StateHasChanged(); } + private async Task SyncToGoogleCalendar() + { + try + { + _errorMessage = string.Empty; + _successMessage = string.Empty; + _isSyncing = true; + StateHasChanged(); + + // Get available calendars + var response = await HttpClient.GetAsync("api/shift/google_calendars"); + if (response.IsSuccessStatusCode) + { + _availableCalendars = await response.Content.ReadFromJsonAsync>() ?? new(); + + if (_availableCalendars.Count == 1) + { + // If only one calendar, sync directly + await PerformCalendarSync(_availableCalendars[0].Id); + } + else if (_availableCalendars.Count > 1) + { + // Show calendar selector + _showCalendarSelector = true; + } + else + { + _errorMessage = "No writable calendars found in your Google account."; + } + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + _errorMessage = $"Failed to retrieve calendars: {response.StatusCode}. {errorContent}"; + } + } + catch (Exception ex) + { + _errorMessage = $"Error accessing Google Calendar: {ex.Message}"; + } + finally + { + _isSyncing = false; + StateHasChanged(); + } + } + + private async Task SelectCalendarAndSync(string calendarId) + { + _showCalendarSelector = false; + await PerformCalendarSync(calendarId); + } + + private async Task PerformCalendarSync(string calendarId) + { + try + { + _isSyncing = true; + StateHasChanged(); + + var shiftsWithTransportList = SelectedShiftsWithTransport.Values.ToList(); + var request = new + { + CalendarId = calendarId, + ShiftsWithTransport = shiftsWithTransportList + }; + + var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_google_calendar", request); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(); + _successMessage = "Shifts synced to Google Calendar successfully!"; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + _errorMessage = $"Failed to sync to Google Calendar: {response.StatusCode}. {errorContent}"; + } + } + catch (Exception ex) + { + _errorMessage = $"Error syncing to Google Calendar: {ex.Message}"; + } + finally + { + _isSyncing = false; + StateHasChanged(); + } + } + + private void CloseCalendarSelector() + { + _showCalendarSelector = false; + _availableCalendars.Clear(); + StateHasChanged(); + } + private async Task SaveScheduleToStorage() { try diff --git a/README.md b/README.md index 426da14..4a78f76 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,11 @@ Perfect for individuals or small teams who need to organize shift work, track sc ### 🔄 **Export Capabilities** - **ICS Export**: Import schedules directly into Google Calendar, Outlook, or any calendar application -- **PDF Export**: Generate printable shift schedules with professional formatting +- **PDF Export**: Generate printable shift schedules with professional formatting +- **Google Calendar Sync**: Direct synchronization with Google Calendar (NEW!) + - Automatic calendar selection for multiple calendars + - Smart event management (updates existing app-created events) + - Respects existing calendar events from other sources - One-click export functionality ### 🚆 **Transport Integration** @@ -175,6 +179,10 @@ See [Docker Configuration Guide](DOCKER_CONFIG.md) for detailed deployment instr ### Exporting Schedules - **ICS Export**: Click "Export to ICS" to download a calendar file compatible with all major calendar applications +- **Google Calendar Sync**: Click "Sync to Google Calendar" to directly sync shifts to your Google Calendar + - If you have multiple calendars, choose which one to sync to + - Existing shift events will be updated automatically + - Other calendar events remain untouched - **PDF Export**: Click "Export to PDF" to generate a printable schedule document ### Managing Configuration diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs index 065e7de..1b4f528 100644 --- a/Server/Controllers/ShiftController.cs +++ b/Server/Controllers/ShiftController.cs @@ -14,17 +14,20 @@ namespace ShiftScheduler.Server.Controllers private readonly PdfExportService _pdfExportService; private readonly ITransportService _transportService; private readonly IConfigurationService _configurationService; + private readonly IGoogleCalendarService _googleCalendarService; public ShiftController( IcsExportService icsService, PdfExportService pdfExportService, ITransportService transportService, - IConfigurationService configurationService) + IConfigurationService configurationService, + IGoogleCalendarService googleCalendarService) { _icsService = icsService; _pdfExportService = pdfExportService; _transportService = transportService; _configurationService = configurationService; + _googleCalendarService = googleCalendarService; } [HttpGet("shifts")] @@ -148,6 +151,34 @@ namespace ShiftScheduler.Server.Controllers return StatusCode(500, $"Error deleting schedule: {ex.Message}"); } } + + [HttpGet("google_calendars")] + public async Task GetGoogleCalendars() + { + try + { + var calendars = await _googleCalendarService.GetCalendarsAsync(); + return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary })); + } + catch (Exception ex) + { + return StatusCode(500, $"Error retrieving calendars: {ex.Message}"); + } + } + + [HttpPost("sync_to_google_calendar")] + public async Task SyncToGoogleCalendar([FromBody] SyncToGoogleCalendarRequest request) + { + try + { + await _googleCalendarService.SyncShiftsToCalendarAsync(request.CalendarId, request.ShiftsWithTransport); + return Ok(new { message = "Shifts synced to Google Calendar successfully!" }); + } + catch (Exception ex) + { + return StatusCode(500, $"Error syncing to Google Calendar: {ex.Message}"); + } + } } public class ShiftTransportRequest @@ -162,4 +193,10 @@ namespace ShiftScheduler.Server.Controllers public int Month { get; set; } public Dictionary Schedule { get; set; } = new(); } + + public class SyncToGoogleCalendarRequest + { + public string CalendarId { get; set; } = string.Empty; + public List ShiftsWithTransport { get; set; } = []; + } } diff --git a/Server/Program.cs b/Server/Program.cs index 7563ece..6293348 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -28,6 +28,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); // Configure authentication builder.Services.AddAuthentication(options => @@ -51,6 +53,7 @@ builder.Services.AddAuthentication(options => googleOptions.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? ""; googleOptions.CallbackPath = "/signin-google"; googleOptions.SaveTokens = true; + googleOptions.Scope.Add("https://www.googleapis.com/auth/calendar"); googleOptions.Events.OnTicketReceived = async context => { var emailClaim = context.Principal?.FindFirst(ClaimTypes.Email) ?? diff --git a/Services/ConfigurationService.cs b/Services/ConfigurationService.cs index e0d54aa..a4c7cac 100644 --- a/Services/ConfigurationService.cs +++ b/Services/ConfigurationService.cs @@ -5,6 +5,7 @@ namespace ShiftScheduler.Services { public interface IConfigurationService { + string GetTimeZone(); ApplicationConfiguration GetConfiguration(); void UpdateConfiguration(ApplicationConfiguration configuration); Task ExportConfigurationAsync(); @@ -29,7 +30,12 @@ namespace ShiftScheduler.Services private readonly string _shiftsFilePath; private readonly string _transportFilePath; - readonly TimeZoneInfo localTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Zurich"); + private readonly TimeZoneInfo localTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Zurich"); + + public string GetTimeZone() + { + return localTimeZone.Id; + } public ConfigurationService(ApplicationConfiguration initialConfiguration) { diff --git a/Services/GoogleCalendarService.cs b/Services/GoogleCalendarService.cs new file mode 100644 index 0000000..5c7186c --- /dev/null +++ b/Services/GoogleCalendarService.cs @@ -0,0 +1,215 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Calendar.v3; +using Google.Apis.Calendar.v3.Data; +using Google.Apis.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using ShiftScheduler.Shared; + +namespace ShiftScheduler.Services; + +public interface IGoogleCalendarService +{ + Task> GetCalendarsAsync(); + Task SyncShiftsToCalendarAsync(string calendarId, List shifts); +} + +public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService) + : IGoogleCalendarService +{ + public async Task> GetCalendarsAsync() + { + var service = await CreateCalendarServiceAsync(); + var request = service.CalendarList.List(); + var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); + + return response.Items?.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer").ToList() ?? []; + } + + public async Task SyncShiftsToCalendarAsync(string calendarId, List shifts) + { + var service = await CreateCalendarServiceAsync(); + + // Get date range for deletion + if (shifts.Count == 0) return; + + var minDate = shifts.Min(s => s.Date).Date; + var maxDate = shifts.Max(s => s.Date).Date.AddDays(1); + + // Delete existing shift events in the date range + await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate); + + // Add a small delay to avoid rate limiting + await Task.Delay(500); + + // Create new events for each shift with rate limiting protection + await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts); + } + + private async Task DeleteExistingShiftEventsAsync(CalendarService service, string calendarId, DateTime startDate, DateTime endDate) + { + var request = service.Events.List(calendarId); + request.TimeMinDateTimeOffset = startDate; + request.TimeMaxDateTimeOffset = endDate; + request.SingleEvents = true; + request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime; + + var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); + + if (response.Items != null) + { + var shiftsToDelete = response.Items.Where(e => + e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true).ToList(); + + // Delete events with rate limiting protection + foreach (var eventToDelete in shiftsToDelete) + { + await ExecuteWithRetryAsync(async () => + { + await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync(); + return true; // Return something for the generic method + }); + + // Small delay between deletions to avoid rate limiting + if (shiftsToDelete.Count > 1) + await Task.Delay(200); + } + } + } + + private async Task CreateShiftEventsWithRateLimitingAsync(CalendarService service, string calendarId, List shifts) + { + foreach (var shiftWithTransport in shifts) + { + await CreateShiftEventsAsync(service, calendarId, shiftWithTransport); + + // Add delay between shift processing to avoid rate limiting + if (shifts.Count > 1) + await Task.Delay(300); + } + } + + private async Task CreateShiftEventsAsync(CalendarService service, string calendarId, ShiftWithTransport shiftWithTransport) + { + var shift = shiftWithTransport.Shift; + var date = shiftWithTransport.Date; + + // Skip shifts with no time information + if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime)) + return; + + // Create morning event if it exists + if (!string.IsNullOrEmpty(shift.MorningTime)) + { + var morningEvent = CreateEventFromShift(shift, date, shift.MorningTime, shiftWithTransport.MorningTransport); + await ExecuteWithRetryAsync(async () => await service.Events.Insert(morningEvent, calendarId).ExecuteAsync()); + } + + // Create afternoon event if it exists + if (!string.IsNullOrEmpty(shift.AfternoonTime)) + { + var afternoonEvent = CreateEventFromShift(shift, date, shift.AfternoonTime, shiftWithTransport.AfternoonTransport); + await ExecuteWithRetryAsync(async () => await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync()); + } + } + + private Event CreateEventFromShift(Shift shift, DateTime date, string timeRange, TransportConnection? transport) + { + var times = timeRange.Split('-'); + var startTime = date.Add(TimeSpan.Parse(times[0])); + var endTime = date.Add(TimeSpan.Parse(times[1])); + + var summary = $"{shift.Name}"; + var description = ""; + + if (transport != null) + { + var transportInfo = FormatTransportInfo(transport); + description = $"Transport: {transportInfo}"; + } + + return new Event + { + Summary = summary, + Description = description, + Start = new EventDateTime + { + DateTimeDateTimeOffset = startTime, + TimeZone = configurationService.GetTimeZone() + }, + End = new EventDateTime + { + DateTimeDateTimeOffset = endTime, + TimeZone = configurationService.GetTimeZone() + }, + ExtendedProperties = new Event.ExtendedPropertiesData + { + Private__ = new Dictionary + { + ["shiftSchedulerEvent"] = "true", + ["shiftName"] = shift.Name + } + } + }; + } + + private static string FormatTransportInfo(TransportConnection transport) + { + var departure = transport.DepartureTime.ToString("HH:mm"); + var arrival = transport.ArrivalTime.ToString("HH:mm"); + return $"{transport.Platform}: {departure} → {arrival} ({transport.Duration.TotalMinutes} Minutes)"; + } + + private async Task CreateCalendarServiceAsync() + { + var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"); + + var accessToken = await httpContext.GetTokenAsync("access_token"); + if (string.IsNullOrEmpty(accessToken)) + { + throw new UnauthorizedAccessException("No access token available"); + } + + var credential = GoogleCredential.FromAccessToken(accessToken); + + return new CalendarService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "ShiftScheduler" + }); + } + + private static async Task ExecuteWithRetryAsync(Func> operation, int maxRetries = 3) + { + var delay = TimeSpan.FromSeconds(1); + + for (var attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + return await operation(); + } + catch (HttpRequestException ex) when (ex.Message.Contains("429") && attempt < maxRetries) + { + // Rate limit exceeded (HTTP 429), wait with exponential backoff + await Task.Delay(delay); + delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff + } + catch (Exception ex) when (ex.Message.Contains("Rate Limit") && attempt < maxRetries) + { + // Another form of rate limit error + await Task.Delay(delay); + delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff + } + catch (Exception ex) when (ex.Message.Contains("Forbidden") && ex.Message.Contains("quota") && attempt < maxRetries) + { + // Quota exceeded error + await Task.Delay(delay); + delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff + } + } + + // If we get here, all retries failed, execute one more time to get the actual exception + return await operation(); + } +} \ No newline at end of file diff --git a/Services/IcsExportService.cs b/Services/IcsExportService.cs index 8ab3ab4..28d752e 100644 --- a/Services/IcsExportService.cs +++ b/Services/IcsExportService.cs @@ -23,7 +23,7 @@ namespace ShiftScheduler.Services if (!string.IsNullOrEmpty(shift.MorningTime)) { var times = shift.MorningTime.Split('-'); - var summary = $"{shift.Name} (Morning)"; + var summary = $"{shift.Name}"; var description = ""; if (shiftWithTransport.MorningTransport != null) @@ -44,7 +44,7 @@ namespace ShiftScheduler.Services if (!string.IsNullOrEmpty(shift.AfternoonTime)) { var times = shift.AfternoonTime.Split('-'); - var summary = $"{shift.Name} (Afternoon)"; + var summary = $"{shift.Name}"; var description = ""; if (shiftWithTransport.AfternoonTransport != null) diff --git a/Services/ShiftScheduler.Services.csproj b/Services/ShiftScheduler.Services.csproj index fb248fc..a4e95c2 100644 --- a/Services/ShiftScheduler.Services.csproj +++ b/Services/ShiftScheduler.Services.csproj @@ -11,7 +11,10 @@ + + + diff --git a/Shared/GoogleCalendar.cs b/Shared/GoogleCalendar.cs new file mode 100644 index 0000000..81ca4e0 --- /dev/null +++ b/Shared/GoogleCalendar.cs @@ -0,0 +1,7 @@ +namespace ShiftScheduler.Shared; + +public class GoogleCalendar +{ + public string Id { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; +} \ No newline at end of file