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 <c.schaad@pog.ch>
This commit is contained in:
parent
3ed54f51ad
commit
5103aecacf
10 changed files with 439 additions and 8 deletions
|
|
@ -7,9 +7,10 @@
|
||||||
<button class="btn month-btn @(_isCurrentMonth ? "" : "selected")" @onclick="SelectNextMonth" disabled="@_isLoadingInitial">@MonthAndYear.NextMonthName</button>
|
<button class="btn month-btn @(_isCurrentMonth ? "" : "selected")" @onclick="SelectNextMonth" disabled="@_isLoadingInitial">@MonthAndYear.NextMonthName</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-buttons">
|
<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="ExportToIcs" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-calendar" aria-hidden="true"></span> Export to ICS</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 export-btn" @onclick="SyncToGoogleCalendar" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Google Calendar</button>
|
||||||
<button class="btn config-btn" @onclick="ShowConfiguration" disabled="@_isLoadingInitial"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
|
<button class="btn export-btn" @onclick="ExportToPdf" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-document" aria-hidden="true"></span>Export to PDF</button>
|
||||||
|
<button class="btn config-btn" @onclick="ShowConfiguration" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -31,6 +32,47 @@
|
||||||
</div>
|
</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)
|
@if (_isLoadingInitial)
|
||||||
{
|
{
|
||||||
<div class="loading-overlay">
|
<div class="loading-overlay">
|
||||||
|
|
@ -40,6 +82,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
else if (_isSyncing)
|
||||||
|
{
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="loading-spinner">⏳</div>
|
||||||
|
<p>Syncing with google calendar...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,11 @@ namespace ShiftScheduler.Client.Pages
|
||||||
private bool _isLoadingTransport = false;
|
private bool _isLoadingTransport = false;
|
||||||
private bool _isLoadingInitial = false;
|
private bool _isLoadingInitial = false;
|
||||||
private bool _showConfigDialog = false;
|
private bool _showConfigDialog = false;
|
||||||
|
private bool _isSyncing = false;
|
||||||
|
private bool _showCalendarSelector = false;
|
||||||
private string _errorMessage = string.Empty;
|
private string _errorMessage = string.Empty;
|
||||||
private string _successMessage = string.Empty;
|
private string _successMessage = string.Empty;
|
||||||
|
private List<GoogleCalendar> _availableCalendars = new();
|
||||||
|
|
||||||
private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
|
private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
|
||||||
|
|
||||||
|
|
@ -162,6 +165,104 @@ namespace ShiftScheduler.Client.Pages
|
||||||
StateHasChanged();
|
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()
|
private async Task SaveScheduleToStorage()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -23,7 +23,11 @@ Perfect for individuals or small teams who need to organize shift work, track sc
|
||||||
|
|
||||||
### 🔄 **Export Capabilities**
|
### 🔄 **Export Capabilities**
|
||||||
- **ICS Export**: Import schedules directly into Google Calendar, Outlook, or any calendar application
|
- **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
|
- One-click export functionality
|
||||||
|
|
||||||
### 🚆 **Transport Integration**
|
### 🚆 **Transport Integration**
|
||||||
|
|
@ -175,6 +179,10 @@ See [Docker Configuration Guide](DOCKER_CONFIG.md) for detailed deployment instr
|
||||||
|
|
||||||
### Exporting Schedules
|
### Exporting Schedules
|
||||||
- **ICS Export**: Click "Export to ICS" to download a calendar file compatible with all major calendar applications
|
- **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
|
- **PDF Export**: Click "Export to PDF" to generate a printable schedule document
|
||||||
|
|
||||||
### Managing Configuration
|
### Managing Configuration
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,20 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
private readonly PdfExportService _pdfExportService;
|
private readonly PdfExportService _pdfExportService;
|
||||||
private readonly ITransportService _transportService;
|
private readonly ITransportService _transportService;
|
||||||
private readonly IConfigurationService _configurationService;
|
private readonly IConfigurationService _configurationService;
|
||||||
|
private readonly IGoogleCalendarService _googleCalendarService;
|
||||||
|
|
||||||
public ShiftController(
|
public ShiftController(
|
||||||
IcsExportService icsService,
|
IcsExportService icsService,
|
||||||
PdfExportService pdfExportService,
|
PdfExportService pdfExportService,
|
||||||
ITransportService transportService,
|
ITransportService transportService,
|
||||||
IConfigurationService configurationService)
|
IConfigurationService configurationService,
|
||||||
|
IGoogleCalendarService googleCalendarService)
|
||||||
{
|
{
|
||||||
_icsService = icsService;
|
_icsService = icsService;
|
||||||
_pdfExportService = pdfExportService;
|
_pdfExportService = pdfExportService;
|
||||||
_transportService = transportService;
|
_transportService = transportService;
|
||||||
_configurationService = configurationService;
|
_configurationService = configurationService;
|
||||||
|
_googleCalendarService = googleCalendarService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("shifts")]
|
[HttpGet("shifts")]
|
||||||
|
|
@ -148,6 +151,34 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
return StatusCode(500, $"Error deleting schedule: {ex.Message}");
|
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
|
public class ShiftTransportRequest
|
||||||
|
|
@ -162,4 +193,10 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
public Dictionary<DateTime, string> Schedule { get; set; } = new();
|
public Dictionary<DateTime, string> Schedule { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class SyncToGoogleCalendarRequest
|
||||||
|
{
|
||||||
|
public string CalendarId { get; set; } = string.Empty;
|
||||||
|
public List<ShiftWithTransport> ShiftsWithTransport { get; set; } = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ builder.Services.AddSingleton<IcsExportService>();
|
||||||
builder.Services.AddSingleton<PdfExportService>();
|
builder.Services.AddSingleton<PdfExportService>();
|
||||||
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
||||||
builder.Services.AddSingleton<ITransportService, TransportService>();
|
builder.Services.AddSingleton<ITransportService, TransportService>();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
|
||||||
|
|
||||||
// Configure authentication
|
// Configure authentication
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
|
|
@ -51,6 +53,7 @@ builder.Services.AddAuthentication(options =>
|
||||||
googleOptions.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "";
|
googleOptions.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "";
|
||||||
googleOptions.CallbackPath = "/signin-google";
|
googleOptions.CallbackPath = "/signin-google";
|
||||||
googleOptions.SaveTokens = true;
|
googleOptions.SaveTokens = true;
|
||||||
|
googleOptions.Scope.Add("https://www.googleapis.com/auth/calendar");
|
||||||
googleOptions.Events.OnTicketReceived = async context =>
|
googleOptions.Events.OnTicketReceived = async context =>
|
||||||
{
|
{
|
||||||
var emailClaim = context.Principal?.FindFirst(ClaimTypes.Email) ??
|
var emailClaim = context.Principal?.FindFirst(ClaimTypes.Email) ??
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace ShiftScheduler.Services
|
||||||
{
|
{
|
||||||
public interface IConfigurationService
|
public interface IConfigurationService
|
||||||
{
|
{
|
||||||
|
string GetTimeZone();
|
||||||
ApplicationConfiguration GetConfiguration();
|
ApplicationConfiguration GetConfiguration();
|
||||||
void UpdateConfiguration(ApplicationConfiguration configuration);
|
void UpdateConfiguration(ApplicationConfiguration configuration);
|
||||||
Task<string> ExportConfigurationAsync();
|
Task<string> ExportConfigurationAsync();
|
||||||
|
|
@ -29,7 +30,12 @@ namespace ShiftScheduler.Services
|
||||||
private readonly string _shiftsFilePath;
|
private readonly string _shiftsFilePath;
|
||||||
private readonly string _transportFilePath;
|
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)
|
public ConfigurationService(ApplicationConfiguration initialConfiguration)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
215
Services/GoogleCalendarService.cs
Normal file
215
Services/GoogleCalendarService.cs
Normal file
|
|
@ -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<List<CalendarListEntry>> GetCalendarsAsync();
|
||||||
|
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
|
||||||
|
: IGoogleCalendarService
|
||||||
|
{
|
||||||
|
public async Task<List<CalendarListEntry>> 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<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);
|
||||||
|
|
||||||
|
// 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<ShiftWithTransport> 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<string, string>
|
||||||
|
{
|
||||||
|
["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<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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ namespace ShiftScheduler.Services
|
||||||
if (!string.IsNullOrEmpty(shift.MorningTime))
|
if (!string.IsNullOrEmpty(shift.MorningTime))
|
||||||
{
|
{
|
||||||
var times = shift.MorningTime.Split('-');
|
var times = shift.MorningTime.Split('-');
|
||||||
var summary = $"{shift.Name} (Morning)";
|
var summary = $"{shift.Name}";
|
||||||
var description = "";
|
var description = "";
|
||||||
|
|
||||||
if (shiftWithTransport.MorningTransport != null)
|
if (shiftWithTransport.MorningTransport != null)
|
||||||
|
|
@ -44,7 +44,7 @@ namespace ShiftScheduler.Services
|
||||||
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
if (!string.IsNullOrEmpty(shift.AfternoonTime))
|
||||||
{
|
{
|
||||||
var times = shift.AfternoonTime.Split('-');
|
var times = shift.AfternoonTime.Split('-');
|
||||||
var summary = $"{shift.Name} (Afternoon)";
|
var summary = $"{shift.Name}";
|
||||||
var description = "";
|
var description = "";
|
||||||
|
|
||||||
if (shiftWithTransport.AfternoonTransport != null)
|
if (shiftWithTransport.AfternoonTransport != null)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Google.Apis.Calendar.v3" Version="1.69.0.3746" />
|
||||||
<PackageReference Include="Ical.Net" Version="5.1.0" />
|
<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="QuestPDF" Version="2025.7.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
7
Shared/GoogleCalendar.cs
Normal file
7
Shared/GoogleCalendar.cs
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue