# 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 ``` **2b. Update the calendar selector modal title** — change: ```html ``` To: ```html ``` **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