feat: add Nextcloud CalDAV calendar sync

Implements a provider-agnostic calendar sync feature with Nextcloud as the
first alternative to Google Calendar.

- Add ICalendarService interface and provider-agnostic API endpoints
- Implement NextcloudCalendarService using CalDAV (Ical.Net)
- Add CalendarEventBuilder to share event/transport logic across providers
- Add Nextcloud sync button to the UI (Index page)
- Centralise TargetFramework, Nullable and ImplicitUsings in Directory.Build.props (net9.0)
- Add .worktrees to .gitignore and Docker/Nextcloud config docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-04-13 20:00:18 +02:00
parent 71717cccc1
commit e8e7e07ffc
23 changed files with 2316 additions and 286 deletions

3
.gitignore vendored
View file

@ -63,3 +63,6 @@ nunit-*.xml
# Configuration files (uncomment to avoid committing development configs) # Configuration files (uncomment to avoid committing development configs)
config/ config/
**/config/ **/config/
# Git worktrees
.worktrees/

View file

@ -9,6 +9,7 @@
<div class="action-buttons"> <div class="action-buttons">
<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="ExportToIcs" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-calendar" aria-hidden="true"></span> Export to ICS</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 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 export-btn" @onclick="SyncToNextcloud" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Nextcloud</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 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> <button class="btn config-btn" @onclick="ShowConfiguration" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
</div> </div>
@ -38,7 +39,7 @@
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Select Google Calendar</h5> <h5 class="modal-title">Select @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google") Calendar</h5>
<button type="button" class="btn-close" @onclick="CloseCalendarSelector"></button> <button type="button" class="btn-close" @onclick="CloseCalendarSelector"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -87,7 +88,7 @@ else if (_isSyncing)
<div class="loading-overlay"> <div class="loading-overlay">
<div class="loading-content"> <div class="loading-content">
<div class="loading-spinner">⏳</div> <div class="loading-spinner">⏳</div>
<p>Syncing with google calendar...</p> <p>Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...</p>
</div> </div>
</div> </div>
} }
@ -218,10 +219,3 @@ else
OnClose="HideConfiguration" OnClose="HideConfiguration"
OnConfigurationChanged="OnConfigurationChanged" /> OnConfigurationChanged="OnConfigurationChanged" />
@code {
// Helper method to check if a specific day is loading transport data
private bool IsLoadingTransportForDay(DateTime day)
{
return _isLoadingTransportPerDay.GetValueOrDefault(day, false);
}
}

View file

@ -10,7 +10,7 @@ namespace ShiftScheduler.Client.Pages
[Inject] private HttpClient HttpClient { get; set; } = default!; [Inject] private HttpClient HttpClient { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!; [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!;
private List<Shift> Shifts { get; set; } = new(); private List<Shift> Shifts { get; set; } = new();
private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new(); private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new();
private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new(); private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new();
@ -21,24 +21,22 @@ namespace ShiftScheduler.Client.Pages
private bool _showConfigDialog = false; private bool _showConfigDialog = false;
private bool _isSyncing = false; private bool _isSyncing = false;
private bool _showCalendarSelector = false; private bool _showCalendarSelector = false;
private string _syncProvider = string.Empty;
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 List<CalendarInfo> _availableCalendars = new();
private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next(); private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_isLoadingInitial = true; _isLoadingInitial = true;
// Load shifts first to show the calendar immediately
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new(); Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
// Show calendar immediately, don't wait for transport data
_isLoadingInitial = false; _isLoadingInitial = false;
StateHasChanged(); StateHasChanged();
// Load schedule and transport data in the background
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await LoadScheduleFromStorage(); await LoadScheduleFromStorage();
@ -63,29 +61,25 @@ namespace ShiftScheduler.Client.Pages
private async Task SelectShift(DateTime day, string shiftName) private async Task SelectShift(DateTime day, string shiftName)
{ {
SelectedSchedule[day] = shiftName; SelectedSchedule[day] = shiftName;
// Get transport information for this shift and date - per day loading
_isLoadingTransportPerDay[day] = true; _isLoadingTransportPerDay[day] = true;
StateHasChanged(); StateHasChanged();
try try
{ {
var request = new { ShiftName = shiftName, Date = day }; var request = new { ShiftName = shiftName, Date = day };
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request); var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>(); var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
if (shiftWithTransport != null) if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport; SelectedShiftsWithTransport[day] = shiftWithTransport;
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error fetching transport data: {ex.Message}"); Console.WriteLine($"Error fetching transport data: {ex.Message}");
// Create a fallback with just the shift data
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName); var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null) if (shift != null)
{ {
@ -103,7 +97,7 @@ namespace ShiftScheduler.Client.Pages
_isLoadingTransportPerDay[day] = false; _isLoadingTransportPerDay[day] = false;
StateHasChanged(); StateHasChanged();
} }
await SaveScheduleToStorage(); await SaveScheduleToStorage();
} }
@ -117,7 +111,7 @@ namespace ShiftScheduler.Client.Pages
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month }; var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", request); var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var bytes = await response.Content.ReadAsByteArrayAsync(); var bytes = await response.Content.ReadAsByteArrayAsync();
@ -136,7 +130,7 @@ namespace ShiftScheduler.Client.Pages
{ {
_errorMessage = $"Error exporting ICS file: {ex.Message}"; _errorMessage = $"Error exporting ICS file: {ex.Message}";
} }
StateHasChanged(); StateHasChanged();
} }
@ -150,7 +144,7 @@ namespace ShiftScheduler.Client.Pages
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month }; var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", request); var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var bytes = await response.Content.ReadAsByteArrayAsync(); var bytes = await response.Content.ReadAsByteArrayAsync();
@ -168,38 +162,39 @@ namespace ShiftScheduler.Client.Pages
{ {
_errorMessage = $"Error exporting PDF file: {ex.Message}"; _errorMessage = $"Error exporting PDF file: {ex.Message}";
} }
StateHasChanged(); StateHasChanged();
} }
private async Task SyncToGoogleCalendar() private Task SyncToGoogleCalendar() => StartCalendarSync("google");
private Task SyncToNextcloud() => StartCalendarSync("nextcloud");
private async Task StartCalendarSync(string provider)
{ {
try try
{ {
_errorMessage = string.Empty; _errorMessage = string.Empty;
_successMessage = string.Empty; _successMessage = string.Empty;
_syncProvider = provider;
_isSyncing = true; _isSyncing = true;
StateHasChanged(); StateHasChanged();
// Get available calendars var response = await HttpClient.GetAsync($"api/shift/calendars?provider={provider}");
var response = await HttpClient.GetAsync("api/shift/google_calendars");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
_availableCalendars = await response.Content.ReadFromJsonAsync<List<GoogleCalendar>>() ?? new(); _availableCalendars = await response.Content.ReadFromJsonAsync<List<CalendarInfo>>() ?? new();
if (_availableCalendars.Count == 1) if (_availableCalendars.Count == 1)
{ {
// If only one calendar, sync directly
await PerformCalendarSync(_availableCalendars[0].Id); await PerformCalendarSync(_availableCalendars[0].Id);
} }
else if (_availableCalendars.Count > 1) else if (_availableCalendars.Count > 1)
{ {
// Show calendar selector
_showCalendarSelector = true; _showCalendarSelector = true;
} }
else else
{ {
_errorMessage = "No writable calendars found in your Google account."; _errorMessage = "No writable calendars found.";
} }
} }
else else
@ -210,7 +205,7 @@ namespace ShiftScheduler.Client.Pages
} }
catch (Exception ex) catch (Exception ex)
{ {
_errorMessage = $"Error accessing Google Calendar: {ex.Message}"; _errorMessage = $"Error accessing calendar: {ex.Message}";
} }
finally finally
{ {
@ -234,27 +229,27 @@ namespace ShiftScheduler.Client.Pages
var request = new var request = new
{ {
Provider = _syncProvider,
CalendarId = calendarId, CalendarId = calendarId,
Year = SelectedDate.Year, Year = SelectedDate.Year,
Month = SelectedDate.Month Month = SelectedDate.Month
}; };
var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_google_calendar", request); var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_calendar", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var result = await response.Content.ReadFromJsonAsync<dynamic>(); _successMessage = "Shifts synced successfully!";
_successMessage = "Shifts synced to Google Calendar successfully!";
} }
else else
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_errorMessage = $"Failed to sync to Google Calendar: {response.StatusCode}. {errorContent}"; _errorMessage = $"Failed to sync: {response.StatusCode}. {errorContent}";
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_errorMessage = $"Error syncing to Google Calendar: {ex.Message}"; _errorMessage = $"Error syncing: {ex.Message}";
} }
finally finally
{ {
@ -280,7 +275,7 @@ namespace ShiftScheduler.Client.Pages
Month = SelectedDate.Month, Month = SelectedDate.Month,
Schedule = SelectedSchedule Schedule = SelectedSchedule
}; };
await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request); await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request);
} }
catch (Exception) catch (Exception)
@ -294,12 +289,10 @@ namespace ShiftScheduler.Client.Pages
try try
{ {
var response = await HttpClient.GetAsync($"api/shift/load_schedule/{SelectedDate.Year}/{SelectedDate.Month}"); var response = await HttpClient.GetAsync($"api/shift/load_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new(); SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new();
// Reload transport data for all selected shifts
await ReloadTransportDataForSchedule(); await ReloadTransportDataForSchedule();
} }
else else
@ -318,41 +311,33 @@ namespace ShiftScheduler.Client.Pages
private async Task ReloadTransportDataForSchedule() private async Task ReloadTransportDataForSchedule()
{ {
SelectedShiftsWithTransport.Clear(); SelectedShiftsWithTransport.Clear();
// Clear existing per-day loading states
_isLoadingTransportPerDay.Clear(); _isLoadingTransportPerDay.Clear();
// Mark all days as loading
foreach (var kvp in SelectedSchedule) foreach (var kvp in SelectedSchedule)
{
_isLoadingTransportPerDay[kvp.Key] = true; _isLoadingTransportPerDay[kvp.Key] = true;
}
StateHasChanged(); StateHasChanged();
// Create parallel tasks for all transport data requests
var transportTasks = SelectedSchedule.Select(async kvp => var transportTasks = SelectedSchedule.Select(async kvp =>
{ {
var day = kvp.Key; var day = kvp.Key;
var shiftName = kvp.Value; var shiftName = kvp.Value;
try try
{ {
var request = new { ShiftName = shiftName, Date = day }; var request = new { ShiftName = shiftName, Date = day };
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request); var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>(); var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
if (shiftWithTransport != null) if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport; SelectedShiftsWithTransport[day] = shiftWithTransport;
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}"); Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
// Create fallback without transport
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName); var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null) if (shift != null)
{ {
@ -367,13 +352,11 @@ namespace ShiftScheduler.Client.Pages
} }
finally finally
{ {
// Mark this day as no longer loading
_isLoadingTransportPerDay[day] = false; _isLoadingTransportPerDay[day] = false;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
}).ToArray(); }).ToArray();
// Wait for all transport data to load
await Task.WhenAll(transportTasks); await Task.WhenAll(transportTasks);
} }
@ -381,7 +364,7 @@ namespace ShiftScheduler.Client.Pages
{ {
SelectedSchedule.Clear(); SelectedSchedule.Clear();
SelectedShiftsWithTransport.Clear(); SelectedShiftsWithTransport.Clear();
try try
{ {
await HttpClient.DeleteAsync($"api/shift/delete_schedule/{SelectedDate.Year}/{SelectedDate.Month}"); await HttpClient.DeleteAsync($"api/shift/delete_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
@ -392,45 +375,42 @@ namespace ShiftScheduler.Client.Pages
} }
} }
// Helper method to get transport summary for display
private string GetTransportSummary(DateTime date) private string GetTransportSummary(DateTime date)
{ {
if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport)) if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
return string.Empty; return string.Empty;
var summaries = new List<string>(); var summaries = new List<string>();
var morningTransport = shiftWithTransport.GetMorningTransportSummary(); var morningTransport = shiftWithTransport.GetMorningTransportSummary();
if (!string.IsNullOrEmpty(morningTransport)) if (!string.IsNullOrEmpty(morningTransport))
summaries.Add(morningTransport); summaries.Add(morningTransport);
var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary(); var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary();
if (!string.IsNullOrEmpty(afternoonTransport)) if (!string.IsNullOrEmpty(afternoonTransport))
summaries.Add(afternoonTransport); summaries.Add(afternoonTransport);
if (summaries.Count == 0) { return string.Empty; } if (summaries.Count == 0) return string.Empty;
return "🚂 " + string.Join(" | ", summaries); return "🚂 " + string.Join(" | ", summaries);
} }
// Helper method to get shift times for display
private string GetShiftTimes(DateTime date) private string GetShiftTimes(DateTime date)
{ {
if (!SelectedSchedule.TryGetValue(date, out var shiftName)) if (!SelectedSchedule.TryGetValue(date, out var shiftName))
return string.Empty; return string.Empty;
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName); var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift == null) if (shift == null) return string.Empty;
return string.Empty;
var times = new List<string>(); var times = new List<string>();
if (!string.IsNullOrEmpty(shift.MorningTime)) if (!string.IsNullOrEmpty(shift.MorningTime))
times.Add($"🌅 {shift.MorningTime}"); times.Add($"🌅 {shift.MorningTime}");
if (!string.IsNullOrEmpty(shift.AfternoonTime)) if (!string.IsNullOrEmpty(shift.AfternoonTime))
times.Add($"🌆 {shift.AfternoonTime}"); times.Add($"🌆 {shift.AfternoonTime}");
return string.Join(" | ", times); return string.Join(" | ", times);
} }
@ -448,9 +428,11 @@ namespace ShiftScheduler.Client.Pages
private async Task OnConfigurationChanged() private async Task OnConfigurationChanged()
{ {
// Reload shifts after configuration changes
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new(); Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
StateHasChanged(); StateHasChanged();
} }
private bool IsLoadingTransportForDay(DateTime day) =>
_isLoadingTransportPerDay.GetValueOrDefault(day, false);
} }
} }

View file

@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.8" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -61,4 +61,18 @@ No migration needed - existing installations will automatically:
## File Format ## File Format
Configuration files use standard JSON format and can be edited directly if needed. Changes take effect after application restart. Configuration files use standard JSON format and can be edited directly if needed. Changes take effect after application restart.
## Nextcloud Calendar Sync
To enable Nextcloud CalDAV sync, populate the `Nextcloud` section in the production `appsettings.json` (not committed to git):
```json
"Nextcloud": {
"BaseUrl": "https://your-nextcloud-instance.example.com",
"Username": "your-nextcloud-username",
"AppPassword": "your-nextcloud-app-password"
}
```
Generate an app password in Nextcloud: **Settings → Security → Devices & sessions → Create new app password**.

7
Directory.Build.props Normal file
View file

@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View file

@ -15,19 +15,22 @@ namespace ShiftScheduler.Server.Controllers
private readonly ITransportService _transportService; private readonly ITransportService _transportService;
private readonly IConfigurationService _configurationService; private readonly IConfigurationService _configurationService;
private readonly IGoogleCalendarService _googleCalendarService; private readonly IGoogleCalendarService _googleCalendarService;
private readonly INextcloudCalendarService _nextcloudCalendarService;
public ShiftController( public ShiftController(
IcsExportService icsService, IcsExportService icsService,
PdfExportService pdfExportService, PdfExportService pdfExportService,
ITransportService transportService, ITransportService transportService,
IConfigurationService configurationService, IConfigurationService configurationService,
IGoogleCalendarService googleCalendarService) IGoogleCalendarService googleCalendarService,
INextcloudCalendarService nextcloudCalendarService)
{ {
_icsService = icsService; _icsService = icsService;
_pdfExportService = pdfExportService; _pdfExportService = pdfExportService;
_transportService = transportService; _transportService = transportService;
_configurationService = configurationService; _configurationService = configurationService;
_googleCalendarService = googleCalendarService; _googleCalendarService = googleCalendarService;
_nextcloudCalendarService = nextcloudCalendarService;
} }
[HttpGet("shifts")] [HttpGet("shifts")]
@ -41,59 +44,38 @@ namespace ShiftScheduler.Server.Controllers
{ {
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName); var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName);
if (shift == null) if (shift == null)
{
return NotFound($"Shift '{request.ShiftName}' not found"); return NotFound($"Shift '{request.ShiftName}' not found");
}
var transportConfig = _configurationService.GetTransportConfiguration(); var transportConfig = _configurationService.GetTransportConfiguration();
TransportConnection? morningTransport = null; TransportConnection? morningTransport = null;
TransportConnection? afternoonTransport = null; TransportConnection? afternoonTransport = null;
var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift); var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift);
// Get transport for morning shift if it has morning time if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningStart.HasValue)
if (!string.IsNullOrEmpty(shift.MorningTime)) morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
{
if (shifTimes.MorningStart.HasValue)
{
morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
}
}
// Get transport for afternoon shift if it has afternoon time
// But only if the break between morning and afternoon is long enough
if (!string.IsNullOrEmpty(shift.AfternoonTime)) if (!string.IsNullOrEmpty(shift.AfternoonTime))
{ {
var shouldLoadAfternoonTransport = true; var shouldLoad = true;
// If both morning and afternoon shifts exist, check break duration if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
if (!string.IsNullOrEmpty(shift.MorningTime) && !string.IsNullOrEmpty(shift.AfternoonTime))
{ {
if (shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue) var breakMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
{ shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
var breakDurationMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
}
}
if (shouldLoadAfternoonTransport)
{
if (shifTimes.AfternoonStart.HasValue)
{
afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
}
} }
if (shouldLoad && shifTimes.AfternoonStart.HasValue)
afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
} }
var shiftWithTransport = new ShiftWithTransport return Ok(new ShiftWithTransport
{ {
Date = request.Date, Date = request.Date,
Shift = shift, Shift = shift,
MorningTransport = morningTransport, MorningTransport = morningTransport,
AfternoonTransport = afternoonTransport, AfternoonTransport = afternoonTransport
}; });
return Ok(shiftWithTransport);
} }
[HttpPost("export_ics")] [HttpPost("export_ics")]
@ -154,13 +136,22 @@ namespace ShiftScheduler.Server.Controllers
} }
} }
[HttpGet("google_calendars")] [HttpGet("calendars")]
public async Task<IActionResult> GetGoogleCalendars() public async Task<IActionResult> GetCalendars([FromQuery] string provider)
{ {
try try
{ {
var calendars = await _googleCalendarService.GetCalendarsAsync(); ICalendarService service = provider switch
return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary })); {
"google" => (ICalendarService)_googleCalendarService,
"nextcloud" => _nextcloudCalendarService,
_ => null!
};
if (service is null)
return BadRequest($"Unknown provider '{provider}'. Supported values: google, nextcloud.");
var calendars = await service.GetCalendarsAsync();
return Ok(calendars);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -168,18 +159,28 @@ namespace ShiftScheduler.Server.Controllers
} }
} }
[HttpPost("sync_to_google_calendar")] [HttpPost("sync_to_calendar")]
public async Task<IActionResult> SyncToGoogleCalendar([FromBody] SyncToGoogleCalendarRequest request) public async Task<IActionResult> SyncToCalendar([FromBody] SyncToCalendarRequest request)
{ {
try try
{ {
var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month); var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month);
await _googleCalendarService.SyncShiftsToCalendarAsync(request.CalendarId, shiftsWithTransport);
return Ok(new { message = "Shifts synced to Google Calendar successfully!" }); ICalendarService service = request.Provider switch
{
"google" => (ICalendarService)_googleCalendarService,
"nextcloud" => _nextcloudCalendarService,
_ => null!
};
if (service is null)
return BadRequest($"Unknown provider '{request.Provider}'. Supported values: google, nextcloud.");
await service.SyncShiftsToCalendarAsync(request.CalendarId, shiftsWithTransport);
return Ok(new { message = "Shifts synced successfully!" });
} }
catch (Exception ex) catch (Exception ex)
{ {
return StatusCode(500, $"Error syncing to Google Calendar: {ex.Message}"); return StatusCode(500, $"Error syncing to calendar: {ex.Message}");
} }
} }
@ -193,37 +194,29 @@ namespace ShiftScheduler.Server.Controllers
{ {
var date = kvp.Key; var date = kvp.Key;
var shiftName = kvp.Value; var shiftName = kvp.Value;
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == shiftName); var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == shiftName);
if (shift == null) if (shift == null) continue;
continue;
var shiftTimes = _configurationService.ParseShiftTimes(date, shift); var shiftTimes = _configurationService.ParseShiftTimes(date, shift);
TransportConnection? morningTransport = null; TransportConnection? morningTransport = null;
TransportConnection? afternoonTransport = null; TransportConnection? afternoonTransport = null;
// Get transport for morning shift if it has morning time
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningStart.HasValue) if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningStart.HasValue)
{
morningTransport = await _transportService.GetConnectionAsync(shiftTimes.MorningStart.Value); morningTransport = await _transportService.GetConnectionAsync(shiftTimes.MorningStart.Value);
}
// Get transport for afternoon shift if it has afternoon time
if (!string.IsNullOrEmpty(shift.AfternoonTime)) if (!string.IsNullOrEmpty(shift.AfternoonTime))
{ {
var shouldLoadAfternoonTransport = true; var shouldLoad = true;
// If both morning and afternoon shifts exist, check break duration
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningEnd.HasValue && shiftTimes.AfternoonStart.HasValue) if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningEnd.HasValue && shiftTimes.AfternoonStart.HasValue)
{ {
var breakDurationMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes; var breakMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes; shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
} }
if (shouldLoadAfternoonTransport && shiftTimes.AfternoonStart.HasValue) if (shouldLoad && shiftTimes.AfternoonStart.HasValue)
{
afternoonTransport = await _transportService.GetConnectionAsync(shiftTimes.AfternoonStart.Value); afternoonTransport = await _transportService.GetConnectionAsync(shiftTimes.AfternoonStart.Value);
}
} }
shiftsWithTransport.Add(new ShiftWithTransport shiftsWithTransport.Add(new ShiftWithTransport
@ -258,8 +251,9 @@ namespace ShiftScheduler.Server.Controllers
public int Month { get; set; } public int Month { get; set; }
} }
public class SyncToGoogleCalendarRequest public class SyncToCalendarRequest
{ {
public string Provider { get; set; } = string.Empty;
public string CalendarId { get; set; } = string.Empty; public string CalendarId { get; set; } = string.Empty;
public int Year { get; set; } public int Year { get; set; }
public int Month { get; set; } public int Month { get; set; }

View file

@ -30,6 +30,7 @@ builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
builder.Services.AddSingleton<ITransportService, TransportService>(); builder.Services.AddSingleton<ITransportService, TransportService>();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>(); builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
builder.Services.AddScoped<INextcloudCalendarService, NextcloudCalendarService>();
// Configure authentication // Configure authentication
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>

View file

@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
@ -17,5 +11,4 @@
<ProjectReference Include="..\Services\ShiftScheduler.Services.csproj" /> <ProjectReference Include="..\Services\ShiftScheduler.Services.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -18,6 +18,12 @@
] ]
}, },
"Nextcloud": {
"BaseUrl": "https://your-nextcloud-instance.example.com",
"Username": "YOUR_NEXTCLOUD_USERNAME",
"AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD"
},
"Transport": { "Transport": {
"StartStation": "Zurich", "StartStation": "Zurich",
"EndStation": "Basel", "EndStation": "Basel",

View file

@ -0,0 +1,53 @@
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services;
public record CalendarEventData(string Summary, string Description);
public static class CalendarEventBuilder
{
public static CalendarEventData BuildEventContent(Shift shift, TransportConnection? transport)
{
var description = transport != null
? $"Transport: {FormatTransportInfo(transport)}"
: string.Empty;
return new CalendarEventData(shift.Name, description);
}
public static string FormatTransportInfo(TransportConnection transport)
{
var departure = transport.DepartureTime.ToString("HH:mm");
var arrival = transport.ArrivalTime.ToString("HH:mm");
return $"{transport.Platform}: {departure} → {arrival} ({(int)transport.Duration.TotalMinutes} Minutes)";
}
public 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)
{
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
catch (Exception ex) when (ex.Message.Contains("Rate Limit") && attempt < maxRetries)
{
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
catch (Exception ex) when (ex.Message.Contains("Forbidden") && ex.Message.Contains("quota") && attempt < maxRetries)
{
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
}
return await operation();
}
}

View file

@ -8,41 +8,32 @@ using ShiftScheduler.Shared;
namespace ShiftScheduler.Services; namespace ShiftScheduler.Services;
public interface IGoogleCalendarService
{
Task<List<CalendarListEntry>> GetCalendarsAsync();
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
}
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService) public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
: IGoogleCalendarService : IGoogleCalendarService
{ {
public async Task<List<CalendarListEntry>> GetCalendarsAsync() public async Task<List<CalendarInfo>> GetCalendarsAsync()
{ {
var service = await CreateCalendarServiceAsync(); var service = await CreateCalendarServiceAsync();
var request = service.CalendarList.List(); var request = service.CalendarList.List();
var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
return response.Items?.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer").ToList() ?? []; return response.Items?
.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer")
.Select(c => new CalendarInfo { Id = c.Id, Summary = c.Summary })
.ToList() ?? [];
} }
public async Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts) public async Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts)
{ {
var service = await CreateCalendarServiceAsync(); var service = await CreateCalendarServiceAsync();
// Get date range for deletion
if (shifts.Count == 0) return; if (shifts.Count == 0) return;
var minDate = shifts.Min(s => s.Date).Date; var minDate = shifts.Min(s => s.Date).Date;
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1); var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
// Delete existing shift events in the date range
await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate); await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate);
// Add a small delay to avoid rate limiting
await Task.Delay(500); await Task.Delay(500);
// Create new events for each shift with rate limiting protection
await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts); await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts);
} }
@ -53,24 +44,23 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
request.TimeMaxDateTimeOffset = endDate; request.TimeMaxDateTimeOffset = endDate;
request.SingleEvents = true; request.SingleEvents = true;
request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime; request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime;
var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync()); var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
if (response.Items != null) if (response.Items != null)
{ {
var shiftsToDelete = response.Items.Where(e => var shiftsToDelete = response.Items
e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true).ToList(); .Where(e => e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true)
.ToList();
// Delete events with rate limiting protection
foreach (var eventToDelete in shiftsToDelete) foreach (var eventToDelete in shiftsToDelete)
{ {
await ExecuteWithRetryAsync(async () => await CalendarEventBuilder.ExecuteWithRetryAsync(async () =>
{ {
await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync(); await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync();
return true; // Return something for the generic method return true;
}); });
// Small delay between deletions to avoid rate limiting
if (shiftsToDelete.Count > 1) if (shiftsToDelete.Count > 1)
await Task.Delay(200); await Task.Delay(200);
} }
@ -82,8 +72,7 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
foreach (var shiftWithTransport in shifts) foreach (var shiftWithTransport in shifts)
{ {
await CreateShiftEventsAsync(service, calendarId, shiftWithTransport); await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
// Add delay between shift processing to avoid rate limiting
if (shifts.Count > 1) if (shifts.Count > 1)
await Task.Delay(300); await Task.Delay(300);
} }
@ -94,118 +83,59 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
var shift = shiftWithTransport.Shift; var shift = shiftWithTransport.Shift;
var date = shiftWithTransport.Date; var date = shiftWithTransport.Date;
// Skip shifts with no time information
if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime)) if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
return; return;
// Create morning event if it exists
if (!string.IsNullOrEmpty(shift.MorningTime)) if (!string.IsNullOrEmpty(shift.MorningTime))
{ {
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime); var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime);
var morningEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.MorningTransport); var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
await ExecuteWithRetryAsync(async () => await service.Events.Insert(morningEvent, calendarId).ExecuteAsync()); var googleEvent = CreateGoogleEvent(eventData, startTime, endTime);
await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await service.Events.Insert(googleEvent, calendarId).ExecuteAsync());
} }
// Create afternoon event if it exists
if (!string.IsNullOrEmpty(shift.AfternoonTime)) if (!string.IsNullOrEmpty(shift.AfternoonTime))
{ {
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime); var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime);
var afternoonEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.AfternoonTransport); var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport);
await ExecuteWithRetryAsync(async () => await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync()); var googleEvent = CreateGoogleEvent(eventData, startTime, endTime);
await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await service.Events.Insert(googleEvent, calendarId).ExecuteAsync());
} }
} }
private Event CreateEventFromShift(Shift shift, DateTimeOffset startTime, DateTimeOffset endTime, TransportConnection? transport) private static Event CreateGoogleEvent(CalendarEventData eventData, DateTimeOffset startTime, DateTimeOffset endTime)
{ {
var summary = $"{shift.Name}";
var description = "";
if (transport != null)
{
var transportInfo = FormatTransportInfo(transport);
description = $"Transport: {transportInfo}";
}
return new Event return new Event
{ {
Summary = summary, Summary = eventData.Summary,
Description = description, Description = eventData.Description,
Start = new EventDateTime Start = new EventDateTime { DateTimeDateTimeOffset = startTime },
{ End = new EventDateTime { DateTimeDateTimeOffset = endTime },
DateTimeDateTimeOffset = startTime
},
End = new EventDateTime
{
DateTimeDateTimeOffset = endTime
},
ExtendedProperties = new Event.ExtendedPropertiesData ExtendedProperties = new Event.ExtendedPropertiesData
{ {
Private__ = new Dictionary<string, string> Private__ = new Dictionary<string, string>
{ {
["shiftSchedulerEvent"] = "true", ["shiftSchedulerEvent"] = "true",
["shiftName"] = shift.Name ["shiftName"] = eventData.Summary
} }
} }
}; };
} }
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() private async Task<CalendarService> CreateCalendarServiceAsync()
{ {
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"); var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
var accessToken = await httpContext.GetTokenAsync("access_token"); var accessToken = await httpContext.GetTokenAsync("access_token");
if (string.IsNullOrEmpty(accessToken)) if (string.IsNullOrEmpty(accessToken))
{
throw new UnauthorizedAccessException("No access token available"); throw new UnauthorizedAccessException("No access token available");
}
var credential = GoogleCredential.FromAccessToken(accessToken); var credential = GoogleCredential.FromAccessToken(accessToken);
return new CalendarService(new BaseClientService.Initializer return new CalendarService(new BaseClientService.Initializer
{ {
HttpClientInitializer = credential, HttpClientInitializer = credential,
ApplicationName = "ShiftScheduler" 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();
}
}

View file

@ -0,0 +1,15 @@
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services;
public interface ICalendarService
{
Task<List<CalendarInfo>> GetCalendarsAsync();
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
}
/// <summary>Marker interface for DI disambiguation.</summary>
public interface IGoogleCalendarService : ICalendarService { }
/// <summary>Marker interface for DI disambiguation.</summary>
public interface INextcloudCalendarService : ICalendarService { }

View file

@ -0,0 +1,232 @@
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using Microsoft.Extensions.Configuration;
using ShiftScheduler.Shared;
using System.Net.Http.Headers;
using System.Text;
using System.Xml.Linq;
namespace ShiftScheduler.Services;
public class NextcloudCalendarService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
IConfigurationService configurationService) : INextcloudCalendarService
{
private readonly string _baseUrl = configuration["Nextcloud:BaseUrl"]
?? throw new InvalidOperationException("Nextcloud:BaseUrl not configured");
private readonly string _username = configuration["Nextcloud:Username"]
?? throw new InvalidOperationException("Nextcloud:Username not configured");
private readonly string _appPassword = configuration["Nextcloud:AppPassword"]
?? throw new InvalidOperationException("Nextcloud:AppPassword not configured");
private static readonly XNamespace DavNs = "DAV:";
private static readonly XNamespace CalDavNs = "urn:ietf:params:xml:ns:caldav";
public async Task<List<CalendarInfo>> GetCalendarsAsync()
{
var path = $"/remote.php/dav/calendars/{_username}/";
var body = """
<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:displayname/>
<D:resourcetype/>
</D:prop>
</D:propfind>
""";
var request = CreateRequest(HttpMethod.Parse("PROPFIND"), path);
request.Headers.Add("Depth", "1");
request.Content = new StringContent(body, Encoding.UTF8, "application/xml");
var client = httpClientFactory.CreateClient();
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync();
return ParseCalendarsFromPropfind(xml);
}
public async Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts)
{
if (shifts.Count == 0) return;
var minDate = shifts.Min(s => s.Date).Date;
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
await DeleteExistingShiftEventsAsync(calendarId, minDate, maxDate);
await Task.Delay(500);
await CreateShiftEventsAsync(calendarId, shifts);
}
private List<CalendarInfo> ParseCalendarsFromPropfind(string xml)
{
var doc = XDocument.Parse(xml);
var calendars = new List<CalendarInfo>();
foreach (var responseElement in doc.Descendants(DavNs + "response"))
{
var resourceType = responseElement.Descendants(DavNs + "resourcetype").FirstOrDefault();
if (resourceType?.Element(CalDavNs + "calendar") == null)
continue;
var href = responseElement.Element(DavNs + "href")?.Value ?? string.Empty;
if (string.IsNullOrEmpty(href)) continue;
var displayName = responseElement.Descendants(DavNs + "displayname").FirstOrDefault()?.Value ?? href;
calendars.Add(new CalendarInfo { Id = href, Summary = displayName });
}
return calendars;
}
private async Task DeleteExistingShiftEventsAsync(string calendarId, DateTime startDate, DateTime endDate)
{
var startUtc = startDate.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
var endUtc = endDate.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
var body = $"""
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{startUtc}" end="{endUtc}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
""";
var reportRequest = CreateRequest(HttpMethod.Parse("REPORT"), calendarId);
reportRequest.Headers.Add("Depth", "1");
reportRequest.Content = new StringContent(body, Encoding.UTF8, "application/xml");
var client = httpClientFactory.CreateClient();
var reportResponse = await client.SendAsync(reportRequest);
reportResponse.EnsureSuccessStatusCode();
var xml = await reportResponse.Content.ReadAsStringAsync();
var eventUrls = ParseShiftEventUrls(xml);
foreach (var url in eventUrls)
{
var deleteRequest = CreateRequest(HttpMethod.Delete, url);
var deleteResponse = await client.SendAsync(deleteRequest);
if (!deleteResponse.IsSuccessStatusCode &&
deleteResponse.StatusCode != System.Net.HttpStatusCode.NotFound)
deleteResponse.EnsureSuccessStatusCode();
if (eventUrls.Count > 1)
await Task.Delay(200);
}
}
private List<string> ParseShiftEventUrls(string xml)
{
var doc = XDocument.Parse(xml);
var urls = new List<string>();
foreach (var responseElement in doc.Descendants(DavNs + "response"))
{
var calendarData = responseElement
.Descendants(CalDavNs + "calendar-data")
.FirstOrDefault()?.Value;
if (calendarData == null || !calendarData.Contains("X-SHIFT-SCHEDULER:TRUE"))
continue;
var href = responseElement.Element(DavNs + "href")?.Value;
if (!string.IsNullOrEmpty(href))
urls.Add(href);
}
return urls;
}
private async Task CreateShiftEventsAsync(string calendarId, List<ShiftWithTransport> shifts)
{
var client = httpClientFactory.CreateClient();
foreach (var shiftWithTransport in shifts)
{
var shift = shiftWithTransport.Shift;
var date = shiftWithTransport.Date;
if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
continue;
if (!string.IsNullOrEmpty(shift.MorningTime))
{
var (startTime, endTime) = configurationService.GetZurichTime(
DateOnly.FromDateTime(date), shift.MorningTime);
var uid = $"shift-{SanitizeForUid(shift.Name)}-{date:yyyyMMdd}-morning";
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
var icsContent = BuildIcsContent(uid, eventData, startTime, endTime);
var putRequest = CreateRequest(HttpMethod.Put, $"{calendarId}{Uri.EscapeDataString(uid)}.ics");
putRequest.Content = new StringContent(icsContent, Encoding.UTF8, "text/calendar");
(await client.SendAsync(putRequest)).EnsureSuccessStatusCode();
}
if (!string.IsNullOrEmpty(shift.AfternoonTime))
{
var (startTime, endTime) = configurationService.GetZurichTime(
DateOnly.FromDateTime(date), shift.AfternoonTime);
var uid = $"shift-{SanitizeForUid(shift.Name)}-{date:yyyyMMdd}-afternoon";
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport);
var icsContent = BuildIcsContent(uid, eventData, startTime, endTime);
var putRequest = CreateRequest(HttpMethod.Put, $"{calendarId}{Uri.EscapeDataString(uid)}.ics");
putRequest.Content = new StringContent(icsContent, Encoding.UTF8, "text/calendar");
(await client.SendAsync(putRequest)).EnsureSuccessStatusCode();
}
if (shifts.Count > 1)
await Task.Delay(300);
}
}
private static string BuildIcsContent(string uid, CalendarEventData eventData, DateTimeOffset startTime, DateTimeOffset endTime)
{
var calendar = new Calendar();
var vevent = new CalendarEvent
{
Uid = uid,
Summary = eventData.Summary,
Description = eventData.Description,
DtStart = new CalDateTime(startTime.UtcDateTime, hasTime: true),
DtEnd = new CalDateTime(endTime.UtcDateTime, hasTime: true)
};
vevent.Properties.Add(new Ical.Net.CalendarProperty("X-SHIFT-SCHEDULER", "TRUE"));
calendar.Events.Add(vevent);
return new CalendarSerializer().SerializeToString(calendar)!;
}
private static string SanitizeForUid(string name)
{
return new string(name.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
}
private HttpRequestMessage CreateRequest(HttpMethod method, string path)
{
// path may be a URL-path from a CalDAV href ("/remote.php/...") or a full URL
var uri = path.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? new Uri(path)
: new Uri($"{_baseUrl.TrimEnd('/')}{path}");
var request = new HttpRequestMessage(method, uri);
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_username}:{_appPassword}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
return request;
}
}

View file

@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" /> <ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />
</ItemGroup> </ItemGroup>
@ -15,6 +9,8 @@
<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.Authentication.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<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/CalendarInfo.cs Normal file
View file

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

View file

@ -1,7 +1,2 @@
namespace ShiftScheduler.Shared; // Class renamed to CalendarInfo — see Shared/CalendarInfo.cs
namespace ShiftScheduler.Shared;
public class GoogleCalendar
{
public string Id { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
}

View file

@ -1,12 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<SupportedPlatform Include="browser" /> <SupportedPlatform Include="browser" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,69 @@
using ShiftScheduler.Services;
using ShiftScheduler.Shared;
using Shouldly;
namespace ShiftScheduler.Services.Tests;
public class CalendarEventBuilderTests
{
[Fact]
public void BuildEventContent_WithTransport_IncludesTransportInDescription()
{
var shift = new Shift { Name = "Früh" };
var transport = new TransportConnection
{
Platform = "Gleis 3",
DepartureTime = DateTimeOffset.Parse("2026-04-01T05:30:00+02:00"),
ArrivalTime = DateTimeOffset.Parse("2026-04-01T05:55:00+02:00"),
Duration = TimeSpan.FromMinutes(25)
};
var result = CalendarEventBuilder.BuildEventContent(shift, transport);
result.Summary.ShouldBe("Früh");
result.Description.ShouldBe("Transport: Gleis 3: 05:30 → 05:55 (25 Minutes)");
}
[Fact]
public void BuildEventContent_WithoutTransport_EmptyDescription()
{
var shift = new Shift { Name = "Spät" };
var result = CalendarEventBuilder.BuildEventContent(shift, null);
result.Summary.ShouldBe("Spät");
result.Description.ShouldBeEmpty();
}
[Fact]
public void FormatTransportInfo_FormatsCorrectly()
{
var transport = new TransportConnection
{
Platform = "Gleis 3",
DepartureTime = DateTimeOffset.Parse("2026-04-01T05:30:00+02:00"),
ArrivalTime = DateTimeOffset.Parse("2026-04-01T05:55:00+02:00"),
Duration = TimeSpan.FromMinutes(25)
};
var result = CalendarEventBuilder.FormatTransportInfo(transport);
result.ShouldBe("Gleis 3: 05:30 → 05:55 (25 Minutes)");
}
[Fact]
public void FormatTransportInfo_WithNullPlatform_UsesEmptyString()
{
var transport = new TransportConnection
{
Platform = null,
DepartureTime = DateTimeOffset.Parse("2026-04-01T05:30:00+02:00"),
ArrivalTime = DateTimeOffset.Parse("2026-04-01T05:55:00+02:00"),
Duration = TimeSpan.FromMinutes(25)
};
var result = CalendarEventBuilder.FormatTransportInfo(transport);
result.ShouldBe(": 05:30 → 05:55 (25 Minutes)");
}
}

View file

@ -1,9 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
</PropertyGroup> </PropertyGroup>
@ -15,7 +12,7 @@
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Services\ShiftScheduler.Services.csproj" /> <ProjectReference Include="..\Services\ShiftScheduler.Services.csproj" />
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" /> <ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,139 @@
# Nextcloud Calendar Sync — Design Spec
**Date:** 2026-04-10
**Status:** Approved
---
## Overview
Add a second calendar sync target (Nextcloud CalDAV) alongside the existing Google Calendar sync. Shared logic is extracted into a common interface and a static helper, keeping both service implementations independent and self-contained.
---
## Architecture
### Shared interface: `ICalendarService`
Replaces `IGoogleCalendarService`. Both providers implement this interface:
```csharp
public interface ICalendarService
{
Task<List<CalendarInfo>> GetCalendarsAsync();
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
}
public class CalendarInfo
{
public string Id { get; set; }
public string Summary { get; set; }
}
```
### Shared helper: `CalendarEventBuilder` (static class)
Extracts pure data-transformation logic currently inside `GoogleCalendarService`:
- `FormatTransportInfo(TransportConnection)` → string
- `BuildEventContent(Shift, DateTimeOffset start, DateTimeOffset end, TransportConnection?)` → returns a shared `CalendarEventData` record (summary + description)
No orchestration logic lives here — just pure functions with no side effects.
### `GoogleCalendarService`
- Implements `ICalendarService`
- Refactored to use `CalendarEventBuilder` for event content
- All Google client library calls remain here
- `GetCalendarsAsync()` returns `List<CalendarInfo>` mapped from Google's `CalendarListEntry`
- Identification of owned events via `ExtendedProperties.Private["shiftSchedulerEvent"]` unchanged
### `NextcloudCalendarService`
- Implements `ICalendarService`
- Uses `HttpClient` with Basic Auth (username + app password from configuration)
- CalDAV operations:
- **List calendars**: `PROPFIND` on `/remote.php/dav/calendars/<username>/`, depth 1, parse `displayname` and `resourcetype`
- **Delete existing events in range**: `REPORT` (CalDAV `calendar-query`) to find VEVENTs with `X-SHIFT-SCHEDULER:true` property in the date range, then `DELETE` each by URL
- **Create events**: `PUT` a VEVENT (iCalendar format using the existing `Ical.Net` dependency) to `<calendar-url>/<uid>.ics`. Each event includes `X-SHIFT-SCHEDULER:true` as a custom property for future identification.
- UIDs are generated deterministically: `shift-<shiftName>-<dateISO>-<morning|afternoon>` to allow idempotent re-sync if needed
- Retry logic mirrors Google's exponential backoff, extracted from `GoogleCalendarService` into a shared `RetryHelper` static method in `CalendarEventBuilder`
---
## Controller
`ShiftController` is updated:
- Both `IGoogleCalendarService` and `INextcloudCalendarService` injected. These are empty marker interfaces that extend `ICalendarService`, registered with their concrete types in DI. This avoids DI ambiguity while keeping the shared interface as the contract:
```csharp
public interface IGoogleCalendarService : ICalendarService { }
public interface INextcloudCalendarService : ICalendarService { }
```
- Old endpoints `GET /api/shift/google_calendars` and `POST /api/shift/sync_to_google_calendar` are **replaced** by:
- `GET /api/shift/calendars?provider=google|nextcloud` → `List<CalendarInfo>`
- `POST /api/shift/sync_to_calendar` → body: `{ Provider, CalendarId, Year, Month }`
- Provider routing via a `switch` on the `provider` string — no factory abstraction
---
## Client UI
### Buttons
Two separate sync buttons in `Index.razor`:
- **Sync to Google Calendar** (existing, unchanged label)
- **Sync to Nextcloud** (new)
Both buttons are disabled while `_isSyncing` is true.
### State
New field `_syncProvider` (`"google"` or `"nextcloud"`) tracks which provider triggered the current flow.
Existing `_availableCalendars`, `_showCalendarSelector`, and the calendar picker modal are reused for both providers. The modal title changes to "Select Google Calendar" or "Select Nextcloud Calendar" based on `_syncProvider`.
### Client DTO
`GoogleCalendar` record on the client is renamed to `CalendarInfo` to match the server DTO.
---
## Configuration
### `appsettings.json` (placeholder — committed to git)
```json
"Nextcloud": {
"BaseUrl": "https://your-nextcloud-instance.example.com",
"Username": "YOUR_NEXTCLOUD_USERNAME",
"AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD"
}
```
### Production appsettings (not committed, mounted via Docker)
Contains real values for `BaseUrl`, `Username`, and `AppPassword`.
Read in `Program.cs` via `IConfiguration`, injected into `NextcloudCalendarService` — same pattern as `Authentication:Google`.
### `DOCKER_CONFIG.md`
Add a note that the `Nextcloud` section must be populated in the production `appsettings.json`.
---
## Dependencies
No new NuGet packages required:
- CalDAV HTTP calls: plain `HttpClient`
- iCalendar VEVENT generation: `Ical.Net` (already in `ShiftScheduler.Services.csproj`)
- XML parsing for CalDAV responses: `System.Xml` (built-in)
---
## Out of scope
- Two-way sync (read events from calendar back into the scheduler)
- Nextcloud OAuth — app password is sufficient for a personal instance
- Caching of calendar lists