@@ -87,7 +88,7 @@ else if (_isSyncing)
β³
-
Syncing with google calendar...
+
Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...
}
@@ -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);
- }
-}
diff --git a/Client/Pages/Index.razor.cs b/Client/Pages/Index.razor.cs
index 8ba616f..eec64ea 100644
--- a/Client/Pages/Index.razor.cs
+++ b/Client/Pages/Index.razor.cs
@@ -10,7 +10,7 @@ namespace ShiftScheduler.Client.Pages
[Inject] private HttpClient HttpClient { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
-
+
private List
Shifts { get; set; } = new();
private Dictionary SelectedSchedule { get; set; } = new();
private Dictionary SelectedShiftsWithTransport { get; set; } = new();
@@ -21,24 +21,22 @@ 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 _availableCalendars = new();
+ private List _availableCalendars = new();
private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
protected override async Task OnInitializedAsync()
{
_isLoadingInitial = true;
-
- // Load shifts first to show the calendar immediately
+
Shifts = await HttpClient.GetFromJsonAsync>("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();
@@ -63,29 +61,25 @@ namespace ShiftScheduler.Client.Pages
private async Task SelectShift(DateTime day, string shiftName)
{
SelectedSchedule[day] = shiftName;
-
- // Get transport information for this shift and date - per day loading
+
_isLoadingTransportPerDay[day] = true;
StateHasChanged();
-
+
try
{
var request = new { ShiftName = shiftName, Date = day };
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
-
+
if (response.IsSuccessStatusCode)
{
var shiftWithTransport = await response.Content.ReadFromJsonAsync();
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)
{
@@ -103,7 +97,7 @@ namespace ShiftScheduler.Client.Pages
_isLoadingTransportPerDay[day] = false;
StateHasChanged();
}
-
+
await SaveScheduleToStorage();
}
@@ -117,7 +111,7 @@ namespace ShiftScheduler.Client.Pages
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", request);
-
+
if (response.IsSuccessStatusCode)
{
var bytes = await response.Content.ReadAsByteArrayAsync();
@@ -136,7 +130,7 @@ namespace ShiftScheduler.Client.Pages
{
_errorMessage = $"Error exporting ICS file: {ex.Message}";
}
-
+
StateHasChanged();
}
@@ -150,7 +144,7 @@ namespace ShiftScheduler.Client.Pages
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", request);
-
+
if (response.IsSuccessStatusCode)
{
var bytes = await response.Content.ReadAsByteArrayAsync();
@@ -168,38 +162,39 @@ namespace ShiftScheduler.Client.Pages
{
_errorMessage = $"Error exporting PDF file: {ex.Message}";
}
-
+
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>() ?? new();
-
+ _availableCalendars = await response.Content.ReadFromJsonAsync>() ?? new();
+
if (_availableCalendars.Count == 1)
{
- // If only one calendar, sync directly
await PerformCalendarSync(_availableCalendars[0].Id);
}
else if (_availableCalendars.Count > 1)
{
- // Show calendar selector
_showCalendarSelector = true;
}
else
{
- _errorMessage = "No writable calendars found in your Google account.";
+ _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();
- _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
{
@@ -280,7 +275,7 @@ namespace ShiftScheduler.Client.Pages
Month = SelectedDate.Month,
Schedule = SelectedSchedule
};
-
+
await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request);
}
catch (Exception)
@@ -294,12 +289,10 @@ namespace ShiftScheduler.Client.Pages
try
{
var response = await HttpClient.GetAsync($"api/shift/load_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
-
+
if (response.IsSuccessStatusCode)
{
SelectedSchedule = await response.Content.ReadFromJsonAsync>() ?? new();
-
- // Reload transport data for all selected shifts
await ReloadTransportDataForSchedule();
}
else
@@ -318,41 +311,33 @@ 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;
var shiftName = kvp.Value;
-
+
try
{
var request = new { ShiftName = shiftName, Date = day };
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
-
+
if (response.IsSuccessStatusCode)
{
var shiftWithTransport = await response.Content.ReadFromJsonAsync();
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);
}
@@ -381,7 +364,7 @@ namespace ShiftScheduler.Client.Pages
{
SelectedSchedule.Clear();
SelectedShiftsWithTransport.Clear();
-
+
try
{
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)
{
if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
return string.Empty;
var summaries = new List();
-
+
var morningTransport = shiftWithTransport.GetMorningTransportSummary();
if (!string.IsNullOrEmpty(morningTransport))
summaries.Add(morningTransport);
-
+
var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary();
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();
-
+
if (!string.IsNullOrEmpty(shift.MorningTime))
times.Add($"π
{shift.MorningTime}");
-
+
if (!string.IsNullOrEmpty(shift.AfternoonTime))
times.Add($"π {shift.AfternoonTime}");
-
+
return string.Join(" | ", times);
}
@@ -448,9 +428,11 @@ namespace ShiftScheduler.Client.Pages
private async Task OnConfigurationChanged()
{
- // Reload shifts after configuration changes
Shifts = await HttpClient.GetFromJsonAsync>("api/shift/shifts") ?? new();
StateHasChanged();
}
+
+ private bool IsLoadingTransportForDay(DateTime day) =>
+ _isLoadingTransportPerDay.GetValueOrDefault(day, false);
}
}
diff --git a/Client/ShiftScheduler.Client.csproj b/Client/ShiftScheduler.Client.csproj
index edd0de1..ccd019e 100644
--- a/Client/ShiftScheduler.Client.csproj
+++ b/Client/ShiftScheduler.Client.csproj
@@ -1,11 +1,5 @@
-
- net9.0
- enable
- enable
-
-
diff --git a/Client/wwwroot/icons/F1_PF.png b/Client/wwwroot/icons/F1_PF.png
new file mode 100644
index 0000000..9a77978
Binary files /dev/null and b/Client/wwwroot/icons/F1_PF.png differ
diff --git a/DOCKER_CONFIG.md b/DOCKER_CONFIG.md
index 967b82f..55a178e 100644
--- a/DOCKER_CONFIG.md
+++ b/DOCKER_CONFIG.md
@@ -61,4 +61,18 @@ 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.
\ No newline at end of file
+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**.
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..826b3d4
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+ net9.0
+ enable
+ enable
+
+
diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs
index b22ad76..3ce3c89 100644
--- a/Server/Controllers/ShiftController.cs
+++ b/Server/Controllers/ShiftController.cs
@@ -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,
+ 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,59 +44,38 @@ 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;
TransportConnection? afternoonTransport = null;
-
+
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;
-
- // If both morning and afternoon shifts exist, check break duration
- if (!string.IsNullOrEmpty(shift.MorningTime) && !string.IsNullOrEmpty(shift.AfternoonTime))
+ var shouldLoad = true;
+
+ 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;
- }
- }
-
- if (shouldLoadAfternoonTransport)
- {
- if (shifTimes.AfternoonStart.HasValue)
- {
- afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
- }
+ var breakMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
+ shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
}
+
+ 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 GetGoogleCalendars()
+ [HttpGet("calendars")]
+ public async Task 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 SyncToGoogleCalendar([FromBody] SyncToGoogleCalendarRequest request)
+ [HttpPost("sync_to_calendar")]
+ public async Task 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}");
}
}
@@ -193,37 +194,29 @@ namespace ShiftScheduler.Server.Controllers
{
var date = kvp.Key;
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;
-
- // If both morning and afternoon shifts exist, check break duration
+ var shouldLoad = true;
+
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; }
diff --git a/Server/Program.cs b/Server/Program.cs
index 6293348..4a7e574 100644
--- a/Server/Program.cs
+++ b/Server/Program.cs
@@ -30,6 +30,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
+builder.Services.AddScoped();
// Configure authentication
builder.Services.AddAuthentication(options =>
diff --git a/Server/ShiftScheduler.Server.csproj b/Server/ShiftScheduler.Server.csproj
index 9dd91d8..62dca14 100644
--- a/Server/ShiftScheduler.Server.csproj
+++ b/Server/ShiftScheduler.Server.csproj
@@ -1,11 +1,5 @@
-
- net9.0
- enable
- enable
-
-
@@ -17,5 +11,4 @@
-
diff --git a/Server/appsettings.json b/Server/appsettings.json
index 5c8be59..047fa47 100644
--- a/Server/appsettings.json
+++ b/Server/appsettings.json
@@ -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",
diff --git a/Services/CalendarEventBuilder.cs b/Services/CalendarEventBuilder.cs
new file mode 100644
index 0000000..1559827
--- /dev/null
+++ b/Services/CalendarEventBuilder.cs
@@ -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 ExecuteWithRetryAsync(Func> 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();
+ }
+}
diff --git a/Services/GoogleCalendarService.cs b/Services/GoogleCalendarService.cs
index 4fc9312..0a3f17a 100644
--- a/Services/GoogleCalendarService.cs
+++ b/Services/GoogleCalendarService.cs
@@ -8,41 +8,32 @@ using ShiftScheduler.Shared;
namespace ShiftScheduler.Services;
-public interface IGoogleCalendarService
-{
- Task> GetCalendarsAsync();
- Task SyncShiftsToCalendarAsync(string calendarId, List shifts);
-}
-
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
: IGoogleCalendarService
{
- public async Task> GetCalendarsAsync()
+ public async Task> GetCalendarsAsync()
{
var service = await CreateCalendarServiceAsync();
var request = service.CalendarList.List();
- var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
-
- return response.Items?.Where(c => c.AccessRole == "owner" || c.AccessRole == "writer").ToList() ?? [];
+ var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
+
+ 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 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);
}
@@ -53,24 +44,23 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
request.TimeMaxDateTimeOffset = endDate;
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();
-
- // Delete events with rate limiting protection
+ var shiftsToDelete = response.Items
+ .Where(e => e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true)
+ .ToList();
+
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);
}
@@ -82,8 +72,7 @@ public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, ICo
foreach (var shiftWithTransport in shifts)
{
await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
-
- // Add delay between shift processing to avoid rate limiting
+
if (shifts.Count > 1)
await Task.Delay(300);
}
@@ -94,118 +83,59 @@ 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
{
["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 CreateCalendarServiceAsync()
{
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
-
+
var accessToken = await httpContext.GetTokenAsync("access_token");
if (string.IsNullOrEmpty(accessToken))
- {
throw new UnauthorizedAccessException("No access token available");
- }
var credential = GoogleCredential.FromAccessToken(accessToken);
-
+
return new CalendarService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "ShiftScheduler"
});
}
-
- private static async Task ExecuteWithRetryAsync(Func> 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();
- }
-}
\ No newline at end of file
+}
diff --git a/Services/ICalendarService.cs b/Services/ICalendarService.cs
new file mode 100644
index 0000000..d5ce3ce
--- /dev/null
+++ b/Services/ICalendarService.cs
@@ -0,0 +1,15 @@
+using ShiftScheduler.Shared;
+
+namespace ShiftScheduler.Services;
+
+public interface ICalendarService
+{
+ Task> GetCalendarsAsync();
+ Task SyncShiftsToCalendarAsync(string calendarId, List shifts);
+}
+
+/// Marker interface for DI disambiguation.
+public interface IGoogleCalendarService : ICalendarService { }
+
+/// Marker interface for DI disambiguation.
+public interface INextcloudCalendarService : ICalendarService { }
diff --git a/Services/NextcloudCalendarService.cs b/Services/NextcloudCalendarService.cs
new file mode 100644
index 0000000..06fb715
--- /dev/null
+++ b/Services/NextcloudCalendarService.cs
@@ -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> GetCalendarsAsync()
+ {
+ var path = $"/remote.php/dav/calendars/{_username}/";
+ var body = """
+
+
+
+
+
+
+
+ """;
+
+ 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 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 ParseCalendarsFromPropfind(string xml)
+ {
+ var doc = XDocument.Parse(xml);
+ var calendars = new List();
+
+ 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 = $"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ 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 ParseShiftEventUrls(string xml)
+ {
+ var doc = XDocument.Parse(xml);
+ var urls = new List();
+
+ 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 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;
+ }
+}
diff --git a/Services/ShiftScheduler.Services.csproj b/Services/ShiftScheduler.Services.csproj
index a4e95c2..2ddd5ef 100644
--- a/Services/ShiftScheduler.Services.csproj
+++ b/Services/ShiftScheduler.Services.csproj
@@ -1,11 +1,5 @@
-
- net9.0
- enable
- enable
-
-
@@ -15,6 +9,8 @@
+
+
diff --git a/Shared/CalendarInfo.cs b/Shared/CalendarInfo.cs
new file mode 100644
index 0000000..a7df061
--- /dev/null
+++ b/Shared/CalendarInfo.cs
@@ -0,0 +1,7 @@
+namespace ShiftScheduler.Shared;
+
+public class CalendarInfo
+{
+ public string Id { get; set; } = string.Empty;
+ public string Summary { get; set; } = string.Empty;
+}
diff --git a/Shared/GoogleCalendar.cs b/Shared/GoogleCalendar.cs
index 81ca4e0..b8318b6 100644
--- a/Shared/GoogleCalendar.cs
+++ b/Shared/GoogleCalendar.cs
@@ -1,7 +1,2 @@
-namespace ShiftScheduler.Shared;
-
-public class GoogleCalendar
-{
- public string Id { get; set; } = string.Empty;
- public string Summary { get; set; } = string.Empty;
-}
\ No newline at end of file
+// Class renamed to CalendarInfo β see Shared/CalendarInfo.cs
+namespace ShiftScheduler.Shared;
\ No newline at end of file
diff --git a/Shared/ShiftScheduler.Shared.csproj b/Shared/ShiftScheduler.Shared.csproj
index 46f7f7d..63a9106 100644
--- a/Shared/ShiftScheduler.Shared.csproj
+++ b/Shared/ShiftScheduler.Shared.csproj
@@ -1,12 +1,7 @@
-
- net9.0
- enable
- enable
-
-
+
diff --git a/ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs b/ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs
new file mode 100644
index 0000000..62852c5
--- /dev/null
+++ b/ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs
@@ -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)");
+ }
+}
diff --git a/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj b/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj
index feb6af5..651049c 100644
--- a/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj
+++ b/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj
@@ -1,9 +1,6 @@
-ο»Ώ
+
- net9.0
- enable
- enable
false
@@ -15,7 +12,7 @@
-
+
diff --git a/docs/superpowers/plans/2026-04-10-nextcloud-calendar-sync.md b/docs/superpowers/plans/2026-04-10-nextcloud-calendar-sync.md
new file mode 100644
index 0000000..b7eca7c
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-10-nextcloud-calendar-sync.md
@@ -0,0 +1,1614 @@
+# Nextcloud Calendar Sync Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add Nextcloud CalDAV as a second calendar sync target alongside Google Calendar, with shared base logic extracted into a common interface and static helper.
+
+**Architecture:** A new `ICalendarService` interface unifies both providers. A static `CalendarEventBuilder` holds shared pure logic (event content building, transport formatting, retry). `GoogleCalendarService` and `NextcloudCalendarService` each implement a marker interface extending `ICalendarService`. The controller routes to the right service based on a `provider` query/body parameter.
+
+**Tech Stack:** .NET 9, ASP.NET Core, Blazor WASM, Google.Apis.Calendar.v3 (existing), Ical.Net (existing), CalDAV over HttpClient, System.Xml.Linq for CalDAV XML parsing.
+
+**Design spec:** `docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md`
+
+---
+
+## File Map
+
+| Action | File | Responsibility |
+|--------|------|----------------|
+| Create | `Shared/CalendarInfo.cs` | Shared DTO replacing `GoogleCalendar` |
+| Modify | `Shared/GoogleCalendar.cs` | Empty out (class renamed to `CalendarInfo`) |
+| Create | `Services/ICalendarService.cs` | Shared interface + marker interfaces |
+| Create | `Services/CalendarEventBuilder.cs` | Pure helpers: event content, transport format, retry |
+| Modify | `Services/GoogleCalendarService.cs` | Implement `IGoogleCalendarService`, use `CalendarEventBuilder` |
+| Create | `Services/NextcloudCalendarService.cs` | CalDAV implementation of `INextcloudCalendarService` |
+| Modify | `Server/appsettings.json` | Add `Nextcloud` config section with dummy values |
+| Modify | `Server/Program.cs` | Register `NextcloudCalendarService`, `IHttpClientFactory` already present |
+| Modify | `Server/Controllers/ShiftController.cs` | New provider-agnostic endpoints |
+| Modify | `Client/Pages/Index.razor.cs` | Nextcloud sync flow, rename `GoogleCalendar` β `CalendarInfo` |
+| Modify | `Client/Pages/Index.razor` | Add Nextcloud button, dynamic modal title |
+| Modify | `DOCKER_CONFIG.md` | Document Nextcloud config section |
+| Create | `ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs` | Unit tests for `CalendarEventBuilder` |
+
+---
+
+## Task 1: Add `CalendarInfo` shared DTO
+
+**Files:**
+- Create: `Shared/CalendarInfo.cs`
+- Modify: `Shared/GoogleCalendar.cs`
+
+- [ ] **Step 1: Create `Shared/CalendarInfo.cs`**
+
+```csharp
+namespace ShiftScheduler.Shared;
+
+public class CalendarInfo
+{
+ public string Id { get; set; } = string.Empty;
+ public string Summary { get; set; } = string.Empty;
+}
+```
+
+- [ ] **Step 2: Empty out `Shared/GoogleCalendar.cs`**
+
+Replace entire file content with:
+
+```csharp
+// Class renamed to CalendarInfo β see Shared/CalendarInfo.cs
+namespace ShiftScheduler.Shared;
+```
+
+- [ ] **Step 3: Verify build**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet build
+```
+
+Expected: Build succeeds (no code uses `GoogleCalendar` yet β references are updated in later tasks).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Shared/CalendarInfo.cs Shared/GoogleCalendar.cs
+git commit -m "feat: add CalendarInfo shared DTO, retire GoogleCalendar"
+```
+
+---
+
+## Task 2: Create `ICalendarService` interface
+
+**Files:**
+- Create: `Services/ICalendarService.cs`
+
+- [ ] **Step 1: Create `Services/ICalendarService.cs`**
+
+```csharp
+using ShiftScheduler.Shared;
+
+namespace ShiftScheduler.Services;
+
+public interface ICalendarService
+{
+ Task> GetCalendarsAsync();
+ Task SyncShiftsToCalendarAsync(string calendarId, List shifts);
+}
+
+/// Marker interface for DI disambiguation.
+public interface IGoogleCalendarService : ICalendarService { }
+
+/// Marker interface for DI disambiguation.
+public interface INextcloudCalendarService : ICalendarService { }
+```
+
+- [ ] **Step 2: Verify build**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet build
+```
+
+Expected: Build succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Services/ICalendarService.cs
+git commit -m "feat: add ICalendarService interface and marker interfaces"
+```
+
+---
+
+## Task 3: Create `CalendarEventBuilder` with tests
+
+**Files:**
+- Create: `ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs`
+- Create: `Services/CalendarEventBuilder.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs`:
+
+```csharp
+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)");
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to confirm they fail**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet test ShiftScheduler.Services.Tests/ --filter "CalendarEventBuilderTests" -v minimal
+```
+
+Expected: Build error β `CalendarEventBuilder` does not exist yet.
+
+- [ ] **Step 3: Create `Services/CalendarEventBuilder.cs`**
+
+```csharp
+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} ({transport.Duration.TotalMinutes} Minutes)";
+ }
+
+ public static async Task ExecuteWithRetryAsync(Func> 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();
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to confirm they pass**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet test ShiftScheduler.Services.Tests/ --filter "CalendarEventBuilderTests" -v minimal
+```
+
+Expected: 4 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Services/CalendarEventBuilder.cs ShiftScheduler.Services.Tests/CalendarEventBuilderTests.cs
+git commit -m "feat: add CalendarEventBuilder with shared event/transport logic"
+```
+
+---
+
+## Task 4: Refactor `GoogleCalendarService`
+
+**Files:**
+- Modify: `Services/GoogleCalendarService.cs`
+
+The refactoring:
+1. Change implemented interface from `IGoogleCalendarService` (old, deleted) to the new `IGoogleCalendarService : ICalendarService`
+2. Change `GetCalendarsAsync()` return type from `List` to `List`
+3. Replace `FormatTransportInfo` and the summary/description building with `CalendarEventBuilder`
+4. Replace the private `ExecuteWithRetryAsync` with `CalendarEventBuilder.ExecuteWithRetryAsync`
+
+- [ ] **Step 1: Replace `Services/GoogleCalendarService.cs`**
+
+```csharp
+using Google.Apis.Auth.OAuth2;
+using Google.Apis.Calendar.v3;
+using Google.Apis.Calendar.v3.Data;
+using Google.Apis.Services;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using ShiftScheduler.Shared;
+
+namespace ShiftScheduler.Services;
+
+public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
+ : IGoogleCalendarService
+{
+ public async Task> GetCalendarsAsync()
+ {
+ var service = await CreateCalendarServiceAsync();
+ var request = service.CalendarList.List();
+ var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
+
+ 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 shifts)
+ {
+ var service = await CreateCalendarServiceAsync();
+
+ 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(service, calendarId, minDate, maxDate);
+ await Task.Delay(500);
+ await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts);
+ }
+
+ private async Task DeleteExistingShiftEventsAsync(CalendarService service, string calendarId, DateTime startDate, DateTime endDate)
+ {
+ var request = service.Events.List(calendarId);
+ request.TimeMinDateTimeOffset = startDate;
+ request.TimeMaxDateTimeOffset = endDate;
+ request.SingleEvents = true;
+ request.OrderBy = EventsResource.ListRequest.OrderByEnum.StartTime;
+
+ var response = await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
+
+ if (response.Items != null)
+ {
+ var shiftsToDelete = response.Items
+ .Where(e => e.ExtendedProperties?.Private__?.ContainsKey("shiftSchedulerEvent") == true)
+ .ToList();
+
+ foreach (var eventToDelete in shiftsToDelete)
+ {
+ await CalendarEventBuilder.ExecuteWithRetryAsync(async () =>
+ {
+ await service.Events.Delete(calendarId, eventToDelete.Id).ExecuteAsync();
+ return true;
+ });
+
+ if (shiftsToDelete.Count > 1)
+ await Task.Delay(200);
+ }
+ }
+ }
+
+ private async Task CreateShiftEventsWithRateLimitingAsync(CalendarService service, string calendarId, List shifts)
+ {
+ foreach (var shiftWithTransport in shifts)
+ {
+ await CreateShiftEventsAsync(service, calendarId, shiftWithTransport);
+
+ if (shifts.Count > 1)
+ await Task.Delay(300);
+ }
+ }
+
+ private async Task CreateShiftEventsAsync(CalendarService service, string calendarId, ShiftWithTransport shiftWithTransport)
+ {
+ var shift = shiftWithTransport.Shift;
+ var date = shiftWithTransport.Date;
+
+ if (string.IsNullOrEmpty(shift.MorningTime) && string.IsNullOrEmpty(shift.AfternoonTime))
+ return;
+
+ if (!string.IsNullOrEmpty(shift.MorningTime))
+ {
+ var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.MorningTime);
+ var eventData = CalendarEventBuilder.BuildEventContent(shift, shiftWithTransport.MorningTransport);
+ var googleEvent = CreateGoogleEvent(eventData, startTime, endTime);
+ await CalendarEventBuilder.ExecuteWithRetryAsync(async () => await service.Events.Insert(googleEvent, calendarId).ExecuteAsync());
+ }
+
+ if (!string.IsNullOrEmpty(shift.AfternoonTime))
+ {
+ var (startTime, endTime) = configurationService.GetZurichTime(DateOnly.FromDateTime(date), shift.AfternoonTime);
+ 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 static Event CreateGoogleEvent(CalendarEventData eventData, DateTimeOffset startTime, DateTimeOffset endTime)
+ {
+ return new Event
+ {
+ Summary = eventData.Summary,
+ Description = eventData.Description,
+ Start = new EventDateTime { DateTimeDateTimeOffset = startTime },
+ End = new EventDateTime { DateTimeDateTimeOffset = endTime },
+ ExtendedProperties = new Event.ExtendedPropertiesData
+ {
+ Private__ = new Dictionary
+ {
+ ["shiftSchedulerEvent"] = "true",
+ ["shiftName"] = eventData.Summary
+ }
+ }
+ };
+ }
+
+ private async Task CreateCalendarServiceAsync()
+ {
+ var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
+
+ var accessToken = await httpContext.GetTokenAsync("access_token");
+ if (string.IsNullOrEmpty(accessToken))
+ throw new UnauthorizedAccessException("No access token available");
+
+ var credential = GoogleCredential.FromAccessToken(accessToken);
+
+ return new CalendarService(new BaseClientService.Initializer
+ {
+ HttpClientInitializer = credential,
+ ApplicationName = "ShiftScheduler"
+ });
+ }
+}
+```
+
+- [ ] **Step 2: Build and run all tests**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet build && dotnet test ShiftScheduler.Services.Tests/ -v minimal
+```
+
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Services/GoogleCalendarService.cs
+git commit -m "refactor: GoogleCalendarService implements ICalendarService, uses CalendarEventBuilder"
+```
+
+---
+
+## Task 5: Add Nextcloud configuration
+
+**Files:**
+- Modify: `Server/appsettings.json`
+- Modify: `DOCKER_CONFIG.md`
+
+- [ ] **Step 1: Add Nextcloud section to `Server/appsettings.json`**
+
+Add after the `"Authentication"` block:
+
+```json
+"Nextcloud": {
+ "BaseUrl": "https://your-nextcloud-instance.example.com",
+ "Username": "YOUR_NEXTCLOUD_USERNAME",
+ "AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD"
+},
+```
+
+The full `Authentication` + `Nextcloud` section in `appsettings.json` should look like:
+
+```json
+"Authentication": {
+ "Google": {
+ "ClientId": "YOUR_GOOGLE_CLIENT_ID",
+ "ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET"
+ },
+ "AuthorizedEmails": [
+ "example1@gmail.com",
+ "example2@gmail.com"
+ ]
+},
+
+"Nextcloud": {
+ "BaseUrl": "https://your-nextcloud-instance.example.com",
+ "Username": "YOUR_NEXTCLOUD_USERNAME",
+ "AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD"
+},
+```
+
+- [ ] **Step 2: Update `DOCKER_CONFIG.md`**
+
+Add a new section at the end of the file:
+
+```markdown
+## 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**.
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Server/appsettings.json DOCKER_CONFIG.md
+git commit -m "feat: add Nextcloud config placeholder and Docker docs"
+```
+
+---
+
+## Task 6: Implement `NextcloudCalendarService`
+
+**Files:**
+- Create: `Services/NextcloudCalendarService.cs`
+
+- [ ] **Step 1: Create `Services/NextcloudCalendarService.cs`**
+
+```csharp
+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> GetCalendarsAsync()
+ {
+ var path = $"/remote.php/dav/calendars/{_username}/";
+ var body = """
+
+
+
+
+
+
+
+ """;
+
+ 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 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 ParseCalendarsFromPropfind(string xml)
+ {
+ var doc = XDocument.Parse(xml);
+ var calendars = new List();
+
+ 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 = $"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ 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 ParseShiftEventUrls(string xml)
+ {
+ var doc = XDocument.Parse(xml);
+ var urls = new List();
+
+ 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 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) { IsUniversalTime = true },
+ DtEnd = new CalDateTime(endTime.UtcDateTime) { IsUniversalTime = 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;
+ }
+}
+```
+
+- [ ] **Step 2: Build**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet build
+```
+
+Expected: Build succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add Services/NextcloudCalendarService.cs
+git commit -m "feat: implement NextcloudCalendarService with CalDAV"
+```
+
+---
+
+## Task 7: Update `Program.cs` and `ShiftController`
+
+**Files:**
+- Modify: `Server/Program.cs`
+- Modify: `Server/Controllers/ShiftController.cs`
+
+- [ ] **Step 1: Update DI registration in `Server/Program.cs`**
+
+Replace the line:
+```csharp
+builder.Services.AddScoped();
+```
+
+With:
+```csharp
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+```
+
+Note: `IHttpClientFactory` is already registered by the existing `builder.Services.AddHttpClient();` call β no additional registration needed.
+
+- [ ] **Step 2: Update `Server/Controllers/ShiftController.cs`**
+
+Replace the full file with:
+
+```csharp
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using ShiftScheduler.Services;
+using ShiftScheduler.Shared;
+
+namespace ShiftScheduler.Server.Controllers
+{
+ [ApiController]
+ [Route("api/[controller]")]
+ [Authorize(Policy = "AllowedEmails")]
+ public class ShiftController : ControllerBase
+ {
+ private readonly IcsExportService _icsService;
+ private readonly PdfExportService _pdfExportService;
+ 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,
+ INextcloudCalendarService nextcloudCalendarService)
+ {
+ _icsService = icsService;
+ _pdfExportService = pdfExportService;
+ _transportService = transportService;
+ _configurationService = configurationService;
+ _googleCalendarService = googleCalendarService;
+ _nextcloudCalendarService = nextcloudCalendarService;
+ }
+
+ [HttpGet("shifts")]
+ public IActionResult GetShifts()
+ {
+ return Ok(_configurationService.GetShifts());
+ }
+
+ [HttpPost("shift_transport")]
+ public async Task GetShiftTransport([FromBody] ShiftTransportRequest request)
+ {
+ 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;
+ TransportConnection? afternoonTransport = null;
+
+ var shifTimes = _configurationService.ParseShiftTimes(request.Date, shift);
+
+ if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningStart.HasValue)
+ morningTransport = await _transportService.GetConnectionAsync(shifTimes.MorningStart.Value);
+
+ if (!string.IsNullOrEmpty(shift.AfternoonTime))
+ {
+ var shouldLoad = true;
+
+ if (!string.IsNullOrEmpty(shift.MorningTime) && shifTimes.MorningEnd.HasValue && shifTimes.AfternoonStart.HasValue)
+ {
+ var breakMinutes = (shifTimes.AfternoonStart.Value - shifTimes.MorningEnd.Value).TotalMinutes;
+ shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
+ }
+
+ if (shouldLoad && shifTimes.AfternoonStart.HasValue)
+ afternoonTransport = await _transportService.GetConnectionAsync(shifTimes.AfternoonStart.Value);
+ }
+
+ return Ok(new ShiftWithTransport
+ {
+ Date = request.Date,
+ Shift = shift,
+ MorningTransport = morningTransport,
+ AfternoonTransport = afternoonTransport
+ });
+ }
+
+ [HttpPost("export_ics")]
+ public async Task ExportIcs([FromBody] ExportRequest request)
+ {
+ var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month);
+ var ics = _icsService.GenerateIcs(shiftsWithTransport);
+ return File(System.Text.Encoding.UTF8.GetBytes(ics), "text/calendar", "schedule.ics");
+ }
+
+ [HttpPost("export_pdf")]
+ public async Task ExportPdf([FromBody] ExportRequest request)
+ {
+ var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month);
+ var pdf = _pdfExportService.GenerateMonthlySchedulePdf(shiftsWithTransport);
+ return File(pdf, "application/pdf", "schedule.pdf");
+ }
+
+ [HttpPost("save_schedule")]
+ public async Task SaveSchedule([FromBody] SaveScheduleRequest request)
+ {
+ try
+ {
+ await _configurationService.SaveScheduleAsync(request.Year, request.Month, request.Schedule);
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Error saving schedule: {ex.Message}");
+ }
+ }
+
+ [HttpGet("load_schedule/{year}/{month}")]
+ public async Task LoadSchedule(int year, int month)
+ {
+ try
+ {
+ var schedule = await _configurationService.LoadScheduleAsync(year, month);
+ return Ok(schedule);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Error loading schedule: {ex.Message}");
+ }
+ }
+
+ [HttpDelete("delete_schedule/{year}/{month}")]
+ public async Task DeleteSchedule(int year, int month)
+ {
+ try
+ {
+ await _configurationService.DeleteScheduleAsync(year, month);
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Error deleting schedule: {ex.Message}");
+ }
+ }
+
+ [HttpGet("calendars")]
+ public async Task GetCalendars([FromQuery] string provider)
+ {
+ try
+ {
+ ICalendarService service = provider == "nextcloud"
+ ? _nextcloudCalendarService
+ : _googleCalendarService;
+
+ var calendars = await service.GetCalendarsAsync();
+ return Ok(calendars);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Error retrieving calendars: {ex.Message}");
+ }
+ }
+
+ [HttpPost("sync_to_calendar")]
+ public async Task SyncToCalendar([FromBody] SyncToCalendarRequest request)
+ {
+ try
+ {
+ var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month);
+
+ ICalendarService service = request.Provider == "nextcloud"
+ ? _nextcloudCalendarService
+ : _googleCalendarService;
+
+ await service.SyncShiftsToCalendarAsync(request.CalendarId, shiftsWithTransport);
+ return Ok(new { message = "Shifts synced successfully!" });
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Error syncing to calendar: {ex.Message}");
+ }
+ }
+
+ private async Task> BuildShiftsWithTransportAsync(int year, int month)
+ {
+ var schedule = await _configurationService.LoadScheduleAsync(year, month);
+ var shiftsWithTransport = new List();
+ var transportConfig = _configurationService.GetTransportConfiguration();
+
+ foreach (var kvp in schedule)
+ {
+ var date = kvp.Key;
+ var shiftName = kvp.Value;
+
+ var shift = _configurationService.GetShifts().FirstOrDefault(s => s.Name == shiftName);
+ if (shift == null) continue;
+
+ var shiftTimes = _configurationService.ParseShiftTimes(date, shift);
+ TransportConnection? morningTransport = null;
+ TransportConnection? afternoonTransport = null;
+
+ if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningStart.HasValue)
+ morningTransport = await _transportService.GetConnectionAsync(shiftTimes.MorningStart.Value);
+
+ if (!string.IsNullOrEmpty(shift.AfternoonTime))
+ {
+ var shouldLoad = true;
+
+ if (!string.IsNullOrEmpty(shift.MorningTime) && shiftTimes.MorningEnd.HasValue && shiftTimes.AfternoonStart.HasValue)
+ {
+ var breakMinutes = (shiftTimes.AfternoonStart.Value - shiftTimes.MorningEnd.Value).TotalMinutes;
+ shouldLoad = breakMinutes >= transportConfig.MinBreakMinutes;
+ }
+
+ if (shouldLoad && shiftTimes.AfternoonStart.HasValue)
+ afternoonTransport = await _transportService.GetConnectionAsync(shiftTimes.AfternoonStart.Value);
+ }
+
+ shiftsWithTransport.Add(new ShiftWithTransport
+ {
+ Date = date,
+ Shift = shift,
+ MorningTransport = morningTransport,
+ AfternoonTransport = afternoonTransport
+ });
+ }
+
+ return shiftsWithTransport;
+ }
+ }
+
+ public class ShiftTransportRequest
+ {
+ public string ShiftName { get; set; } = string.Empty;
+ public DateTime Date { get; set; }
+ }
+
+ public class SaveScheduleRequest
+ {
+ public int Year { get; set; }
+ public int Month { get; set; }
+ public Dictionary Schedule { get; set; } = new();
+ }
+
+ public class ExportRequest
+ {
+ public int Year { get; set; }
+ public int Month { get; set; }
+ }
+
+ 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; }
+ }
+}
+```
+
+- [ ] **Step 3: Build**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet build
+```
+
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Server/Program.cs Server/Controllers/ShiftController.cs
+git commit -m "feat: add provider-agnostic calendar endpoints, register NextcloudCalendarService"
+```
+
+---
+
+## Task 8: Update Blazor client
+
+**Files:**
+- Modify: `Client/Pages/Index.razor.cs`
+- Modify: `Client/Pages/Index.razor`
+
+- [ ] **Step 1: Replace `Client/Pages/Index.razor.cs`**
+
+Replace the full file:
+
+```csharp
+using Microsoft.AspNetCore.Components;
+using Microsoft.JSInterop;
+using System.Net.Http.Json;
+using ShiftScheduler.Shared;
+
+namespace ShiftScheduler.Client.Pages
+{
+ public partial class Index : ComponentBase
+ {
+ [Inject] private HttpClient HttpClient { get; set; } = default!;
+ [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
+ [Inject] private NavigationManager NavigationManager { get; set; } = default!;
+
+ private List Shifts { get; set; } = new();
+ private Dictionary SelectedSchedule { get; set; } = new();
+ private Dictionary SelectedShiftsWithTransport { get; set; } = new();
+ private Dictionary _isLoadingTransportPerDay { get; set; } = new();
+
+ private bool _isCurrentMonth = true;
+ private bool _isLoadingInitial = false;
+ 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 _availableCalendars = new();
+
+ private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
+
+ protected override async Task OnInitializedAsync()
+ {
+ _isLoadingInitial = true;
+
+ Shifts = await HttpClient.GetFromJsonAsync>("api/shift/shifts") ?? new();
+
+ _isLoadingInitial = false;
+ StateHasChanged();
+
+ _ = Task.Run(async () =>
+ {
+ await LoadScheduleFromStorage();
+ await InvokeAsync(StateHasChanged);
+ });
+ }
+
+ private async Task SelectCurrentMonth()
+ {
+ _isCurrentMonth = true;
+ await LoadScheduleFromStorage();
+ StateHasChanged();
+ }
+
+ private async Task SelectNextMonth()
+ {
+ _isCurrentMonth = false;
+ await LoadScheduleFromStorage();
+ StateHasChanged();
+ }
+
+ private async Task SelectShift(DateTime day, string shiftName)
+ {
+ SelectedSchedule[day] = shiftName;
+
+ _isLoadingTransportPerDay[day] = true;
+ StateHasChanged();
+
+ try
+ {
+ var request = new { ShiftName = shiftName, Date = day };
+ var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var shiftWithTransport = await response.Content.ReadFromJsonAsync();
+ if (shiftWithTransport != null)
+ SelectedShiftsWithTransport[day] = shiftWithTransport;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error fetching transport data: {ex.Message}");
+ var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
+ if (shift != null)
+ {
+ SelectedShiftsWithTransport[day] = new ShiftWithTransport
+ {
+ Date = day,
+ Shift = shift,
+ MorningTransport = null,
+ AfternoonTransport = null
+ };
+ }
+ }
+ finally
+ {
+ _isLoadingTransportPerDay[day] = false;
+ StateHasChanged();
+ }
+
+ await SaveScheduleToStorage();
+ }
+
+ private async Task ExportToIcs()
+ {
+ try
+ {
+ _errorMessage = string.Empty;
+ _successMessage = string.Empty;
+ StateHasChanged();
+
+ var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
+ var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var bytes = await response.Content.ReadAsByteArrayAsync();
+ var base64 = Convert.ToBase64String(bytes);
+ var fileUrl = $"data:text/calendar;base64,{base64}";
+ NavigationManager.NavigateTo(fileUrl, true);
+ _successMessage = "ICS file exported successfully!";
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _errorMessage = $"Failed to export ICS file: {response.StatusCode}. {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Error exporting ICS file: {ex.Message}";
+ }
+
+ StateHasChanged();
+ }
+
+ private async Task ExportToPdf()
+ {
+ try
+ {
+ _errorMessage = string.Empty;
+ _successMessage = string.Empty;
+ StateHasChanged();
+
+ var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
+ var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var bytes = await response.Content.ReadAsByteArrayAsync();
+ var base64 = Convert.ToBase64String(bytes);
+ await JSRuntime.InvokeVoidAsync("downloadFile", $"Schedule {SelectedDate.Year}-{SelectedDate.Month:D2}.pdf", "application/pdf", base64);
+ _successMessage = "PDF file exported successfully!";
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _errorMessage = $"Failed to export PDF file: {response.StatusCode}. {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Error exporting PDF file: {ex.Message}";
+ }
+
+ StateHasChanged();
+ }
+
+ 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();
+
+ var response = await HttpClient.GetAsync($"api/shift/calendars?provider={provider}");
+ if (response.IsSuccessStatusCode)
+ {
+ _availableCalendars = await response.Content.ReadFromJsonAsync>() ?? new();
+
+ if (_availableCalendars.Count == 1)
+ {
+ await PerformCalendarSync(_availableCalendars[0].Id);
+ }
+ else if (_availableCalendars.Count > 1)
+ {
+ _showCalendarSelector = true;
+ }
+ else
+ {
+ _errorMessage = "No writable calendars found.";
+ }
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _errorMessage = $"Failed to retrieve calendars: {response.StatusCode}. {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Error accessing calendar: {ex.Message}";
+ }
+ finally
+ {
+ _isSyncing = false;
+ StateHasChanged();
+ }
+ }
+
+ private async Task SelectCalendarAndSync(string calendarId)
+ {
+ _showCalendarSelector = false;
+ await PerformCalendarSync(calendarId);
+ }
+
+ private async Task PerformCalendarSync(string calendarId)
+ {
+ try
+ {
+ _isSyncing = true;
+ StateHasChanged();
+
+ var request = new
+ {
+ Provider = _syncProvider,
+ CalendarId = calendarId,
+ Year = SelectedDate.Year,
+ Month = SelectedDate.Month
+ };
+
+ var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_calendar", request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ _successMessage = "Shifts synced successfully!";
+ }
+ else
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _errorMessage = $"Failed to sync: {response.StatusCode}. {errorContent}";
+ }
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = $"Error syncing: {ex.Message}";
+ }
+ finally
+ {
+ _isSyncing = false;
+ StateHasChanged();
+ }
+ }
+
+ private void CloseCalendarSelector()
+ {
+ _showCalendarSelector = false;
+ _availableCalendars.Clear();
+ StateHasChanged();
+ }
+
+ private async Task SaveScheduleToStorage()
+ {
+ try
+ {
+ var request = new
+ {
+ Year = SelectedDate.Year,
+ Month = SelectedDate.Month,
+ Schedule = SelectedSchedule
+ };
+
+ await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request);
+ }
+ catch (Exception)
+ {
+ // ignored
+ }
+ }
+
+ private async Task LoadScheduleFromStorage()
+ {
+ try
+ {
+ var response = await HttpClient.GetAsync($"api/shift/load_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
+
+ if (response.IsSuccessStatusCode)
+ {
+ SelectedSchedule = await response.Content.ReadFromJsonAsync>() ?? new();
+ await ReloadTransportDataForSchedule();
+ }
+ else
+ {
+ SelectedSchedule = new Dictionary();
+ SelectedShiftsWithTransport = new Dictionary();
+ }
+ }
+ catch (Exception)
+ {
+ SelectedSchedule = new Dictionary();
+ SelectedShiftsWithTransport = new Dictionary();
+ }
+ }
+
+ private async Task ReloadTransportDataForSchedule()
+ {
+ SelectedShiftsWithTransport.Clear();
+ _isLoadingTransportPerDay.Clear();
+
+ foreach (var kvp in SelectedSchedule)
+ _isLoadingTransportPerDay[kvp.Key] = true;
+
+ StateHasChanged();
+
+ var transportTasks = SelectedSchedule.Select(async kvp =>
+ {
+ var day = kvp.Key;
+ var shiftName = kvp.Value;
+
+ try
+ {
+ var request = new { ShiftName = shiftName, Date = day };
+ var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var shiftWithTransport = await response.Content.ReadFromJsonAsync();
+ if (shiftWithTransport != null)
+ SelectedShiftsWithTransport[day] = shiftWithTransport;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
+ var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
+ if (shift != null)
+ {
+ SelectedShiftsWithTransport[day] = new ShiftWithTransport
+ {
+ Date = day,
+ Shift = shift,
+ MorningTransport = null,
+ AfternoonTransport = null
+ };
+ }
+ }
+ finally
+ {
+ _isLoadingTransportPerDay[day] = false;
+ await InvokeAsync(StateHasChanged);
+ }
+ }).ToArray();
+
+ await Task.WhenAll(transportTasks);
+ }
+
+ private async Task ResetSchedule()
+ {
+ SelectedSchedule.Clear();
+ SelectedShiftsWithTransport.Clear();
+
+ try
+ {
+ await HttpClient.DeleteAsync($"api/shift/delete_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
+ }
+ catch (Exception)
+ {
+ // ignored
+ }
+ }
+
+ private string GetTransportSummary(DateTime date)
+ {
+ if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
+ return string.Empty;
+
+ var summaries = new List();
+
+ var morningTransport = shiftWithTransport.GetMorningTransportSummary();
+ if (!string.IsNullOrEmpty(morningTransport))
+ summaries.Add(morningTransport);
+
+ var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary();
+ if (!string.IsNullOrEmpty(afternoonTransport))
+ summaries.Add(afternoonTransport);
+
+ if (summaries.Count == 0) return string.Empty;
+
+ return "π " + string.Join(" | ", summaries);
+ }
+
+ 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;
+
+ var times = new List();
+
+ if (!string.IsNullOrEmpty(shift.MorningTime))
+ times.Add($"π
{shift.MorningTime}");
+
+ if (!string.IsNullOrEmpty(shift.AfternoonTime))
+ times.Add($"π {shift.AfternoonTime}");
+
+ return string.Join(" | ", times);
+ }
+
+ private void ShowConfiguration()
+ {
+ _showConfigDialog = true;
+ StateHasChanged();
+ }
+
+ private void HideConfiguration()
+ {
+ _showConfigDialog = false;
+ StateHasChanged();
+ }
+
+ private async Task OnConfigurationChanged()
+ {
+ Shifts = await HttpClient.GetFromJsonAsync>("api/shift/shifts") ?? new();
+ StateHasChanged();
+ }
+
+ private bool IsLoadingTransportForDay(DateTime day) =>
+ _isLoadingTransportPerDay.GetValueOrDefault(day, false);
+ }
+}
+```
+
+- [ ] **Step 2: Update `Client/Pages/Index.razor`**
+
+Make the following targeted changes to `Index.razor`:
+
+**2a. Add Nextcloud button** β in the `action-buttons` div, after the Google Calendar button, add:
+
+```html
+ Sync to Nextcloud
+```
+
+**2b. Update the calendar selector modal title** β change:
+
+```html
+Select Google Calendar
+```
+
+To:
+
+```html
+Select @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google") Calendar
+```
+
+**2d. Remove the `@code` block at the bottom of `Index.razor`** β the `IsLoadingTransportForDay` helper method was moved into `Index.razor.cs`. Delete this block from `Index.razor`:
+
+```razor
+@code {
+ // Helper method to check if a specific day is loading transport data
+ private bool IsLoadingTransportForDay(DateTime day)
+ {
+ return _isLoadingTransportPerDay.GetValueOrDefault(day, false);
+ }
+}
+```
+
+**2e. Update the syncing loading overlay text** β change:
+
+```html
+Syncing with google calendar...
+```
+
+To:
+
+```html
+Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...
+```
+
+- [ ] **Step 3: Build**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet build
+```
+
+Expected: Build succeeds.
+
+- [ ] **Step 4: Run all tests**
+
+```bash
+cd /home/schaad/Documents/ShiftScheduler && dotnet test ShiftScheduler.Services.Tests/ -v minimal
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Client/Pages/Index.razor.cs Client/Pages/Index.razor
+git commit -m "feat: add Nextcloud sync button and update UI for provider-agnostic calendar flow"
+```
+
+---
+
+## Verification Checklist
+
+After all tasks are complete, verify end-to-end:
+
+- [ ] `dotnet build` succeeds with no warnings on `GoogleCalendar` references
+- [ ] `dotnet test ShiftScheduler.Services.Tests/` β all tests pass
+- [ ] Both "Sync to Google Calendar" and "Sync to Nextcloud" buttons appear in the UI
+- [ ] Modal title changes based on which button was pressed
+- [ ] `GET /api/shift/calendars?provider=google` and `?provider=nextcloud` both return `CalendarInfo[]`
+- [ ] `POST /api/shift/sync_to_calendar` with `Provider: "google"` still works as before
diff --git a/docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md b/docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md
new file mode 100644
index 0000000..47ca150
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md
@@ -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> GetCalendarsAsync();
+ Task SyncShiftsToCalendarAsync(string calendarId, List 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` 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//`, 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 `/.ics`. Each event includes `X-SHIFT-SCHEDULER:true` as a custom property for future identification.
+- UIDs are generated deterministically: `shift---` 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`
+ - `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