feat: add Nextcloud CalDAV calendar sync

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

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

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

3
.gitignore vendored
View file

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

View file

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

View file

@ -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<Shift> Shifts { get; set; } = new();
private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new();
private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new();
@ -21,24 +21,22 @@ namespace ShiftScheduler.Client.Pages
private bool _showConfigDialog = false;
private bool _isSyncing = false;
private bool _showCalendarSelector = false;
private string _syncProvider = string.Empty;
private string _errorMessage = string.Empty;
private string _successMessage = string.Empty;
private List<GoogleCalendar> _availableCalendars = new();
private List<CalendarInfo> _availableCalendars = new();
private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();
protected override async Task OnInitializedAsync()
{
_isLoadingInitial = true;
// Load shifts first to show the calendar immediately
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
// Show calendar immediately, don't wait for transport data
_isLoadingInitial = false;
StateHasChanged();
// Load schedule and transport data in the background
_ = Task.Run(async () =>
{
await LoadScheduleFromStorage();
@ -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<ShiftWithTransport>();
if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error fetching transport data: {ex.Message}");
// Create a fallback with just the shift data
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null)
{
@ -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<List<GoogleCalendar>>() ?? new();
_availableCalendars = await response.Content.ReadFromJsonAsync<List<CalendarInfo>>() ?? new();
if (_availableCalendars.Count == 1)
{
// If only one calendar, sync directly
await PerformCalendarSync(_availableCalendars[0].Id);
}
else if (_availableCalendars.Count > 1)
{
// Show calendar selector
_showCalendarSelector = true;
}
else
{
_errorMessage = "No writable calendars found in your Google account.";
_errorMessage = "No writable calendars found.";
}
}
else
@ -210,7 +205,7 @@ namespace ShiftScheduler.Client.Pages
}
catch (Exception ex)
{
_errorMessage = $"Error accessing Google Calendar: {ex.Message}";
_errorMessage = $"Error accessing calendar: {ex.Message}";
}
finally
{
@ -234,27 +229,27 @@ namespace ShiftScheduler.Client.Pages
var request = new
{
Provider = _syncProvider,
CalendarId = calendarId,
Year = SelectedDate.Year,
Month = SelectedDate.Month
};
var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_google_calendar", request);
var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_calendar", request);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<dynamic>();
_successMessage = "Shifts synced to Google Calendar successfully!";
_successMessage = "Shifts synced successfully!";
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
_errorMessage = $"Failed to sync to Google Calendar: {response.StatusCode}. {errorContent}";
_errorMessage = $"Failed to sync: {response.StatusCode}. {errorContent}";
}
}
catch (Exception ex)
{
_errorMessage = $"Error syncing to Google Calendar: {ex.Message}";
_errorMessage = $"Error syncing: {ex.Message}";
}
finally
{
@ -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<Dictionary<DateTime, string>>() ?? 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<ShiftWithTransport>();
if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
// Create fallback without transport
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null)
{
@ -367,13 +352,11 @@ namespace ShiftScheduler.Client.Pages
}
finally
{
// Mark this day as no longer loading
_isLoadingTransportPerDay[day] = false;
await InvokeAsync(StateHasChanged);
}
}).ToArray();
// Wait for all transport data to load
await Task.WhenAll(transportTasks);
}
@ -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<string>();
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<string>();
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<List<Shift>>("api/shift/shifts") ?? new();
StateHasChanged();
}
private bool IsLoadingTransportForDay(DateTime day) =>
_isLoadingTransportPerDay.GetValueOrDefault(day, false);
}
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -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.
Configuration files use standard JSON format and can be edited directly if needed. Changes take effect after application restart.
## Nextcloud Calendar Sync
To enable Nextcloud CalDAV sync, populate the `Nextcloud` section in the production `appsettings.json` (not committed to git):
```json
"Nextcloud": {
"BaseUrl": "https://your-nextcloud-instance.example.com",
"Username": "your-nextcloud-username",
"AppPassword": "your-nextcloud-app-password"
}
```
Generate an app password in Nextcloud: **Settings → Security → Devices & sessions → Create new app password**.

7
Directory.Build.props Normal file
View file

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

View file

@ -15,19 +15,22 @@ namespace ShiftScheduler.Server.Controllers
private readonly ITransportService _transportService;
private readonly IConfigurationService _configurationService;
private readonly IGoogleCalendarService _googleCalendarService;
private readonly INextcloudCalendarService _nextcloudCalendarService;
public ShiftController(
IcsExportService icsService,
PdfExportService pdfExportService,
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<IActionResult> GetGoogleCalendars()
[HttpGet("calendars")]
public async Task<IActionResult> GetCalendars([FromQuery] string provider)
{
try
{
var calendars = await _googleCalendarService.GetCalendarsAsync();
return Ok(calendars.Select(c => new { Id = c.Id, Summary = c.Summary }));
ICalendarService service = provider switch
{
"google" => (ICalendarService)_googleCalendarService,
"nextcloud" => _nextcloudCalendarService,
_ => null!
};
if (service is null)
return BadRequest($"Unknown provider '{provider}'. Supported values: google, nextcloud.");
var calendars = await service.GetCalendarsAsync();
return Ok(calendars);
}
catch (Exception ex)
{
@ -168,18 +159,28 @@ namespace ShiftScheduler.Server.Controllers
}
}
[HttpPost("sync_to_google_calendar")]
public async Task<IActionResult> SyncToGoogleCalendar([FromBody] SyncToGoogleCalendarRequest request)
[HttpPost("sync_to_calendar")]
public async Task<IActionResult> SyncToCalendar([FromBody] SyncToCalendarRequest request)
{
try
{
var shiftsWithTransport = await BuildShiftsWithTransportAsync(request.Year, request.Month);
await _googleCalendarService.SyncShiftsToCalendarAsync(request.CalendarId, shiftsWithTransport);
return Ok(new { message = "Shifts synced to Google Calendar successfully!" });
ICalendarService service = request.Provider switch
{
"google" => (ICalendarService)_googleCalendarService,
"nextcloud" => _nextcloudCalendarService,
_ => null!
};
if (service is null)
return BadRequest($"Unknown provider '{request.Provider}'. Supported values: google, nextcloud.");
await service.SyncShiftsToCalendarAsync(request.CalendarId, shiftsWithTransport);
return Ok(new { message = "Shifts synced successfully!" });
}
catch (Exception ex)
{
return StatusCode(500, $"Error syncing to Google Calendar: {ex.Message}");
return StatusCode(500, $"Error syncing to calendar: {ex.Message}");
}
}
@ -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; }

View file

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

View file

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

View file

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

View file

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

View file

@ -8,41 +8,32 @@ using ShiftScheduler.Shared;
namespace ShiftScheduler.Services;
public interface IGoogleCalendarService
{
Task<List<CalendarListEntry>> GetCalendarsAsync();
Task SyncShiftsToCalendarAsync(string calendarId, List<ShiftWithTransport> shifts);
}
public class GoogleCalendarService(IHttpContextAccessor httpContextAccessor, IConfigurationService configurationService)
: IGoogleCalendarService
{
public async Task<List<CalendarListEntry>> GetCalendarsAsync()
public async Task<List<CalendarInfo>> GetCalendarsAsync()
{
var service = await CreateCalendarServiceAsync();
var request = service.CalendarList.List();
var response = await ExecuteWithRetryAsync(async () => await request.ExecuteAsync());
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<ShiftWithTransport> shifts)
{
var service = await CreateCalendarServiceAsync();
// Get date range for deletion
if (shifts.Count == 0) return;
var minDate = shifts.Min(s => s.Date).Date;
var maxDate = shifts.Max(s => s.Date).Date.AddDays(1);
// Delete existing shift events in the date range
await DeleteExistingShiftEventsAsync(service, calendarId, minDate, maxDate);
// Add a small delay to avoid rate limiting
await Task.Delay(500);
// Create new events for each shift with rate limiting protection
await CreateShiftEventsWithRateLimitingAsync(service, calendarId, shifts);
}
@ -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<string, string>
{
["shiftSchedulerEvent"] = "true",
["shiftName"] = shift.Name
["shiftName"] = eventData.Summary
}
}
};
}
private static string FormatTransportInfo(TransportConnection transport)
{
var departure = transport.DepartureTime.ToString("HH:mm");
var arrival = transport.ArrivalTime.ToString("HH:mm");
return $"{transport.Platform}: {departure} → {arrival} ({transport.Duration.TotalMinutes} Minutes)";
}
private async Task<CalendarService> CreateCalendarServiceAsync()
{
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available");
var accessToken = await httpContext.GetTokenAsync("access_token");
if (string.IsNullOrEmpty(accessToken))
{
throw new UnauthorizedAccessException("No access token available");
}
var credential = GoogleCredential.FromAccessToken(accessToken);
return new CalendarService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "ShiftScheduler"
});
}
private static async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation, int maxRetries = 3)
{
var delay = TimeSpan.FromSeconds(1);
for (var attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return await operation();
}
catch (HttpRequestException ex) when (ex.Message.Contains("429") && attempt < maxRetries)
{
// Rate limit exceeded (HTTP 429), wait with exponential backoff
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
}
catch (Exception ex) when (ex.Message.Contains("Rate Limit") && attempt < maxRetries)
{
// Another form of rate limit error
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
}
catch (Exception ex) when (ex.Message.Contains("Forbidden") && ex.Message.Contains("quota") && attempt < maxRetries)
{
// Quota exceeded error
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
}
}
// If we get here, all retries failed, execute one more time to get the actual exception
return await operation();
}
}
}

View file

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

View file

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

View file

@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\ShiftScheduler.Shared.csproj" />
</ItemGroup>
@ -15,6 +9,8 @@
<PackageReference Include="Ical.Net" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
<PackageReference Include="QuestPDF" Version="2025.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
</ItemGroup>

7
Shared/CalendarInfo.cs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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