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)
config/
**/config/
# Git worktrees
.worktrees/

View file

@ -9,6 +9,7 @@
<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="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 config-btn" @onclick="ShowConfiguration" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
</div>
@ -38,7 +39,7 @@
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<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>
</div>
<div class="modal-body">
@ -87,7 +88,7 @@ else if (_isSyncing)
<div class="loading-overlay">
<div class="loading-content">
<div class="loading-spinner">⏳</div>
<p>Syncing with google calendar...</p>
<p>Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...</p>
</div>
</div>
}
@ -218,10 +219,3 @@ else
OnClose="HideConfiguration"
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

@ -21,9 +21,10 @@ namespace ShiftScheduler.Client.Pages
private bool _showConfigDialog = false;
private bool _isSyncing = false;
private bool _showCalendarSelector = false;
private string _syncProvider = string.Empty;
private string _errorMessage = 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();
@ -31,14 +32,11 @@ namespace ShiftScheduler.Client.Pages
{
_isLoadingInitial = true;
// Load shifts first to show the calendar immediately
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
// Show calendar immediately, don't wait for transport data
_isLoadingInitial = false;
StateHasChanged();
// Load schedule and transport data in the background
_ = Task.Run(async () =>
{
await LoadScheduleFromStorage();
@ -64,7 +62,6 @@ namespace ShiftScheduler.Client.Pages
{
SelectedSchedule[day] = shiftName;
// Get transport information for this shift and date - per day loading
_isLoadingTransportPerDay[day] = true;
StateHasChanged();
@ -77,15 +74,12 @@ namespace ShiftScheduler.Client.Pages
{
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error fetching transport data: {ex.Message}");
// Create a fallback with just the shift data
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null)
{
@ -172,34 +166,35 @@ namespace ShiftScheduler.Client.Pages
StateHasChanged();
}
private async Task SyncToGoogleCalendar()
private Task SyncToGoogleCalendar() => StartCalendarSync("google");
private Task SyncToNextcloud() => StartCalendarSync("nextcloud");
private async Task StartCalendarSync(string provider)
{
try
{
_errorMessage = string.Empty;
_successMessage = string.Empty;
_syncProvider = provider;
_isSyncing = true;
StateHasChanged();
// Get available calendars
var response = await HttpClient.GetAsync("api/shift/google_calendars");
var response = await HttpClient.GetAsync($"api/shift/calendars?provider={provider}");
if (response.IsSuccessStatusCode)
{
_availableCalendars = await response.Content.ReadFromJsonAsync<List<GoogleCalendar>>() ?? new();
_availableCalendars = await response.Content.ReadFromJsonAsync<List<CalendarInfo>>() ?? 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.";
_errorMessage = "No writable calendars found.";
}
}
else
@ -210,7 +205,7 @@ namespace ShiftScheduler.Client.Pages
}
catch (Exception ex)
{
_errorMessage = $"Error accessing Google Calendar: {ex.Message}";
_errorMessage = $"Error accessing calendar: {ex.Message}";
}
finally
{
@ -234,27 +229,27 @@ namespace ShiftScheduler.Client.Pages
var request = new
{
Provider = _syncProvider,
CalendarId = calendarId,
Year = SelectedDate.Year,
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)
{
var result = await response.Content.ReadFromJsonAsync<dynamic>();
_successMessage = "Shifts synced to Google Calendar successfully!";
_successMessage = "Shifts synced successfully!";
}
else
{
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)
{
_errorMessage = $"Error syncing to Google Calendar: {ex.Message}";
_errorMessage = $"Error syncing: {ex.Message}";
}
finally
{
@ -298,8 +293,6 @@ namespace ShiftScheduler.Client.Pages
if (response.IsSuccessStatusCode)
{
SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new();
// Reload transport data for all selected shifts
await ReloadTransportDataForSchedule();
}
else
@ -318,18 +311,13 @@ namespace ShiftScheduler.Client.Pages
private async Task ReloadTransportDataForSchedule()
{
SelectedShiftsWithTransport.Clear();
// Clear existing per-day loading states
_isLoadingTransportPerDay.Clear();
// Mark all days as loading
foreach (var kvp in SelectedSchedule)
{
_isLoadingTransportPerDay[kvp.Key] = true;
}
StateHasChanged();
// Create parallel tasks for all transport data requests
var transportTasks = SelectedSchedule.Select(async kvp =>
{
var day = kvp.Key;
@ -344,15 +332,12 @@ namespace ShiftScheduler.Client.Pages
{
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
// Create fallback without transport
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null)
{
@ -367,13 +352,11 @@ namespace ShiftScheduler.Client.Pages
}
finally
{
// Mark this day as no longer loading
_isLoadingTransportPerDay[day] = false;
await InvokeAsync(StateHasChanged);
}
}).ToArray();
// Wait for all transport data to load
await Task.WhenAll(transportTasks);
}
@ -392,7 +375,6 @@ namespace ShiftScheduler.Client.Pages
}
}
// Helper method to get transport summary for display
private string GetTransportSummary(DateTime date)
{
if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
@ -408,20 +390,18 @@ namespace ShiftScheduler.Client.Pages
if (!string.IsNullOrEmpty(afternoonTransport))
summaries.Add(afternoonTransport);
if (summaries.Count == 0) { return string.Empty; }
if (summaries.Count == 0) return string.Empty;
return "🚂 " + string.Join(" | ", summaries);
}
// Helper method to get shift times for display
private string GetShiftTimes(DateTime date)
{
if (!SelectedSchedule.TryGetValue(date, out var shiftName))
return string.Empty;
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift == null)
return string.Empty;
if (shift == null) return string.Empty;
var times = new List<string>();
@ -448,9 +428,11 @@ namespace ShiftScheduler.Client.Pages
private async Task OnConfigurationChanged()
{
// Reload shifts after configuration changes
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
StateHasChanged();
}
private bool IsLoadingTransportForDay(DateTime day) =>
_isLoadingTransportPerDay.GetValueOrDefault(day, false);
}
}

View file

@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" 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

@ -62,3 +62,17 @@ No migration needed - existing installations will automatically:
## File Format
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 IConfigurationService _configurationService;
private readonly IGoogleCalendarService _googleCalendarService;
private readonly INextcloudCalendarService _nextcloudCalendarService;
public ShiftController(
IcsExportService icsService,
PdfExportService pdfExportService,
ITransportService transportService,
IConfigurationService configurationService,
IGoogleCalendarService googleCalendarService)
IGoogleCalendarService googleCalendarService,
INextcloudCalendarService nextcloudCalendarService)
{
_icsService = icsService;
_pdfExportService = pdfExportService;
_transportService = transportService;
_configurationService = configurationService;
_googleCalendarService = googleCalendarService;
_nextcloudCalendarService = nextcloudCalendarService;
}
[HttpGet("shifts")]
@ -41,9 +44,7 @@ namespace ShiftScheduler.Server.Controllers
{
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == request.ShiftName);
if (shift == null)
{
return NotFound($"Shift '{request.ShiftName}' not found");
}
var transportConfig = _configurationService.GetTransportConfiguration();
TransportConnection? morningTransport = null;
@ -51,49 +52,30 @@ namespace ShiftScheduler.Server.Controllers
var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift);
// Get transport for morning shift if it has morning time
if (!string.IsNullOrEmpty(shift.MorningTime))
{
if (shifTimes.MorningStart.HasValue)
{
morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
}
}
if (!string.IsNullOrEmpty(shift.MorningTime) && 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))
{
var shouldLoadAfternoonTransport = true;
var shouldLoad = true;
// If both morning and afternoon shifts exist, check break duration
if (!string.IsNullOrEmpty(shift.MorningTime) && !string.IsNullOrEmpty(shift.AfternoonTime))
if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
{
if (shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
{
var breakDurationMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
}
var breakMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
shouldLoad = breakMinutes >= 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,
Shift = shift,
MorningTransport = morningTransport,
AfternoonTransport = afternoonTransport,
};
return Ok(shiftWithTransport);
AfternoonTransport = afternoonTransport
});
}
[HttpPost("export_ics")]
@ -154,13 +136,22 @@ namespace ShiftScheduler.Server.Controllers
}
}
[HttpGet("google_calendars")]
public async Task<IActionResult> GetGoogleCalendars()
[HttpGet("calendars")]
public async Task<IActionResult> GetCalendars([FromQuery] string provider)
{
try
{
var calendars = await _googleCalendarService.GetCalendarsAsync();
return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary }));
ICalendarService service = provider switch
{
"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)
{
@ -168,18 +159,28 @@ namespace ShiftScheduler.Server.Controllers
}
}
[HttpPost("sync_to_google_calendar")]
public async Task<IActionResult> SyncToGoogleCalendar([FromBody] SyncToGoogleCalendarRequest request)
[HttpPost("sync_to_calendar")]
public async Task<IActionResult> SyncToCalendar([FromBody] SyncToCalendarRequest request)
{
try
{
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)
{
return StatusCode(500, $"Error syncing to Google Calendar: {ex.Message}");
return StatusCode(500, $"Error syncing to calendar: {ex.Message}");
}
}
@ -195,35 +196,27 @@ namespace ShiftScheduler.Server.Controllers
var shiftName = kvp.Value;
var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == shiftName);
if (shift == null)
continue;
if (shift == null) continue;
var shiftTimes = _configurationService.ParseShiftTimes(date, shift);
TransportConnection? morningTransport = null;
TransportConnection? afternoonTransport = null;
// Get transport for morning shift if it has morning time
if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningStart.HasValue)
{
morningTransport = await _transportService.GetConnectionAsync(shiftTimes.MorningStart.Value);
}
// Get transport for afternoon shift if it has afternoon time
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)
{
var breakDurationMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
shouldLoadAfternoonTransport = breakDurationMinutes >= transportConfig.MinBreakMinutes;
var breakMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
}
if (shouldLoadAfternoonTransport && shiftTimes.AfternoonStart.HasValue)
{
if (shouldLoad && shiftTimes.AfternoonStart.HasValue)
afternoonTransport = await _transportService.GetConnectionAsync(shiftTimes.AfternoonStart.Value);
}
}
shiftsWithTransport.Add(new ShiftWithTransport
@ -258,8 +251,9 @@ namespace ShiftScheduler.Server.Controllers
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 int Year { 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.AddHttpContextAccessor();
builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
builder.Services.AddScoped<INextcloudCalendarService, NextcloudCalendarService>();
// Configure authentication
builder.Services.AddAuthentication(options =>

View file

@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" 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" />
</ItemGroup>
</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": {
"StartStation": "Zurich",
"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;
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()
public async Task<List<CalendarInfo>> GetCalendarsAsync()
{
var service = await CreateCalendarServiceAsync();
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)
{
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);
}
@ -54,23 +45,22 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
request.SingleEvents = true;
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)
{
var shiftsToDelete = response.Items.Where(e =>
e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true).ToList();
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 CalendarEventBuilder.ExecuteWithRetryAsync(async () =>
{
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)
await Task.Delay(200);
}
@ -83,7 +73,6 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
{
await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
// Add delay between shift processing to avoid rate limiting
if (shifts.Count > 1)
await Task.Delay(300);
}
@ -94,77 +83,52 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
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 (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime);
var morningEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.MorningTransport);
await ExecuteWithRetryAsync(async () => await service.Events.Insert(morningEvent, calendarId).ExecuteAsync());
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
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))
{
var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime);
var afternoonEvent = CreateEventFromShift(shift, startTime, endTime, shiftWithTransport.AfternoonTransport);
await ExecuteWithRetryAsync(async () => await service.Events.Insert(afternoonEvent, calendarId).ExecuteAsync());
var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.AfternoonTransport);
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
{
Summary = summary,
Description = description,
Start = new EventDateTime
{
DateTimeDateTimeOffset = startTime
},
End = new EventDateTime
{
DateTimeDateTimeOffset = endTime
},
Summary = eventData.Summary,
Description = eventData.Description,
Start = new EventDateTime { DateTimeDateTimeOffset = startTime },
End = new EventDateTime { DateTimeDateTimeOffset = endTime },
ExtendedProperties = new Event.ExtendedPropertiesData
{
Private__ = new Dictionary<string, string>
{
["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()
{
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);
@ -174,38 +138,4 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
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">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />
</ItemGroup>
@ -15,6 +9,8 @@
<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="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="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
</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 @@
// 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">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
</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>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

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