diff --git a/Client/Pages/Index.razor b/Client/Pages/Index.razor index 5c1aee9..447fbc7 100644 --- a/Client/Pages/Index.razor +++ b/Client/Pages/Index.razor @@ -8,6 +8,17 @@
+
@@ -31,6 +42,47 @@ } +@if (_showCalendarSelector) +{ + +} + @if (_isLoadingInitial) {
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/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/GoogleCalendarService.cs b/Services/GoogleCalendarService.cs new file mode 100644 index 0000000..0e1c601 --- /dev/null +++ b/Services/GoogleCalendarService.cs @@ -0,0 +1,176 @@ +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 : IGoogleCalendarService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IcsExportService _icsExportService; + + public GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IcsExportService icsExportService) + { + _httpContextAccessor = httpContextAccessor; + _icsExportService = icsExportService; + } + + public async Task> GetCalendarsAsync() + { + var service = await CreateCalendarServiceAsync(); + var request = service.CalendarList.List(); + var response = 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); + + // Create new events for each shift + foreach (var shiftWithTransport in shifts) + { + await CreateShiftEventsAsync(service, calendarId, shiftWithTransport); + } + } + + 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 request.ExecuteAsync(); + + if (response.Items != null) + { + var shiftsToDelete = response.Items.Where(e => + e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true); + + foreach (var eventToDelete in shiftsToDelete) + { + await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync(); + } + } + } + + 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, "Morning", shiftWithTransport.MorningTransport); + 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, "Afternoon", shiftWithTransport.AfternoonTransport); + await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync(); + } + } + + private static Event CreateEventFromShift(Shift shift, DateTime date, string timeRange, string period, 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} ({period})"; + var description = ""; + + if (transport != null && !string.IsNullOrEmpty(transport.DepartureTime)) + { + var transportInfo = FormatTransportInfo(transport); + description = $"Transport: {transportInfo}"; + } + + return new Event + { + Summary = summary, + Description = description, + Start = new EventDateTime + { + DateTimeDateTimeOffset = startTime, + TimeZone = "Europe/Zurich" + }, + End = new EventDateTime + { + DateTimeDateTimeOffset = endTime, + TimeZone = "Europe/Zurich" + }, + ExtendedProperties = new Event.ExtendedPropertiesData + { + Private__ = new Dictionary + { + ["shiftSchedulerEvent"] = "true", + ["shiftName"] = shift.Name, + ["period"] = period.ToLower() + } + } + }; + } + + private static string FormatTransportInfo(TransportConnection transport) + { + var departure = DateTime.TryParse(transport.DepartureTime, out var dep) ? dep.ToString("HH:mm") : transport.DepartureTime; + var arrival = DateTime.TryParse(transport.ArrivalTime, out var arr) ? arr.ToString("HH:mm") : transport.ArrivalTime; + + var mainJourney = transport.Sections?.FirstOrDefault()?.Journey; + if (mainJourney != null) + { + return $"{mainJourney.Category} {mainJourney.Number}: {departure} → {arrival}"; + } + + return $"{departure} → {arrival}"; + } + + 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" + }); + } +} \ No newline at end of file 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