Implement Google Calendar integration with sync button and calendar selection

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-08-30 07:17:03 +00:00 committed by Claudio Schaad
parent 2e4ff2fc58
commit e6e4792d01
7 changed files with 380 additions and 1 deletions

View file

@ -8,6 +8,17 @@
</div>
<div class="action-buttons">
<button class="btn export-btn" @onclick="ExportToIcs" disabled="@_isLoadingInitial"><span class="oi oi-calendar" aria-hidden="true"></span> Export to ICS</button>
<button class="btn export-btn" @onclick="SyncToGoogleCalendar" disabled="@(_isLoadingInitial || _isSyncing)">
@if (_isSyncing)
{
<span class="loading-spinner">⏳</span>
}
else
{
<span class="oi oi-cloud-upload" aria-hidden="true"></span>
}
Sync to Google Calendar
</button>
<button class="btn export-btn" @onclick="ExportToPdf" disabled="@_isLoadingInitial"><span class="oi oi-document" aria-hidden="true"></span>Export to PDF</button>
<button class="btn config-btn" @onclick="ShowConfiguration" disabled="@_isLoadingInitial"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
</div>
@ -31,6 +42,47 @@
</div>
}
@if (_showCalendarSelector)
{
<div class="modal fade show" style="display: block; background-color: rgba(0,0,0,0.5);" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Select Google Calendar</h5>
<button type="button" class="btn-close" @onclick="CloseCalendarSelector"></button>
</div>
<div class="modal-body">
@if (_availableCalendars.Count == 0)
{
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading calendars...</span>
</div>
</div>
<p class="text-center mt-2">Loading your calendars...</p>
}
else
{
<p>Choose which calendar to sync your shifts to:</p>
<div class="list-group">
@foreach (var calendar in _availableCalendars)
{
<button type="button" class="list-group-item list-group-item-action"
@onclick="() => SelectCalendarAndSync(calendar.Id)">
@calendar.Summary
</button>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseCalendarSelector">Cancel</button>
</div>
</div>
</div>
</div>
}
@if (_isLoadingInitial)
{
<div class="loading-overlay">

View file

@ -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<GoogleCalendar> _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<List<GoogleCalendar>>() ?? 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<dynamic>();
_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

View file

@ -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<IActionResult> 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<IActionResult> 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<DateTime, string> Schedule { get; set; } = new();
}
public class SyncToGoogleCalendarRequest
{
public string CalendarId { get; set; } = string.Empty;
public List<ShiftWithTransport> ShiftsWithTransport { get; set; } = [];
}
}

View file

@ -28,6 +28,8 @@ builder.Services.AddSingleton<IcsExportService>();
builder.Services.AddSingleton<PdfExportService>();
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
builder.Services.AddSingleton<ITransportService, TransportService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
// 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) ??

View file

@ -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<List<CalendarListEntry>> GetCalendarsAsync();
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> 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<List<CalendarListEntry>> 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<ShiftWithTransport> 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<string, string>
{
["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<CalendarService> 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"
});
}
}

View file

@ -11,7 +11,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Google.Apis.Calendar.v3" Version="1.69.0.3746" />
<PackageReference Include="Ical.Net" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="QuestPDF" Version="2025.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
</ItemGroup>

7
Shared/GoogleCalendar.cs Normal file
View file

@ -0,0 +1,7 @@
namespace ShiftScheduler.Shared;
public class GoogleCalendar
{
public string Id { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
}