ShiftScheduler/docs/superpowers/plans/2026-04-10-nextcloud-calendar-sync.md
Claudio Schaad 31464fe0f7 Add Nextcloud calendar sync implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:31:41 +02:00

56 KiB

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

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:

// Class renamed to CalendarInfo — see Shared/CalendarInfo.cs
namespace ShiftScheduler.Shared;
  • Step 3: Verify build
cd /home/schaad/Documents/ShiftScheduler && dotnet build

Expected: Build succeeds (no code uses GoogleCalendar yet — references are updated in later tasks).

  • Step 4: Commit
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

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 { }
  • Step 2: Verify build
cd /home/schaad/Documents/ShiftScheduler && dotnet build

Expected: Build succeeds.

  • Step 3: Commit
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:

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
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
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<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();
    }
}
  • Step 4: Run tests to confirm they pass
cd /home/schaad/Documents/ShiftScheduler && dotnet test ShiftScheduler.Services.Tests/ --filter "CalendarEventBuilderTests" -v minimal

Expected: 4 tests pass.

  • Step 5: Commit
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<CalendarListEntry> to List<CalendarInfo>
  3. Replace FormatTransportInfo and the summary/description building with CalendarEventBuilder
  4. Replace the private ExecuteWithRetryAsync with CalendarEventBuilder.ExecuteWithRetryAsync
  • Step 1: Replace Services/GoogleCalendarService.cs
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<List<CalendarInfo>> 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<ShiftWithTransport> 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<ShiftWithTransport> 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<string, string>
                {
                    ["shiftSchedulerEvent"] = "true",
                    ["shiftName"] = eventData.Summary
                }
            }
        };
    }

    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"
        });
    }
}
  • Step 2: Build and run all tests
cd /home/schaad/Documents/ShiftScheduler && dotnet build && dotnet test ShiftScheduler.Services.Tests/ -v minimal

Expected: Build succeeds, all tests pass.

  • Step 3: Commit
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:

"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:

"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:

## 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

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) { 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
cd /home/schaad/Documents/ShiftScheduler && dotnet build

Expected: Build succeeds.

  • Step 3: Commit
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:

builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();

With:

builder.Services.AddScoped<IGoogleCalendarService, GoogleCalendarService>();
builder.Services.AddScoped<INextcloudCalendarService, NextcloudCalendarService>();

Note: IHttpClientFactory is already registered by the existing builder.Services.AddHttpClient<TransportApiService>(); call — no additional registration needed.

  • Step 2: Update Server/Controllers/ShiftController.cs

Replace the full file with:

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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<List<ShiftWithTransport>> BuildShiftsWithTransportAsync(int year, int month)
        {
            var schedule = await _configurationService.LoadScheduleAsync(year, month);
            var shiftsWithTransport = new List<ShiftWithTransport>();
            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<DateTime, string> 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
cd /home/schaad/Documents/ShiftScheduler && dotnet build

Expected: Build succeeds.

  • Step 4: Commit
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:

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<Shift> Shifts { get; set; } = new();
        private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new();
        private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new();
        private Dictionary<DateTime, bool> _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<CalendarInfo> _availableCalendars = new();

        private MonthAndYear SelectedDate => _isCurrentMonth ? MonthAndYear.Current() : MonthAndYear.Next();

        protected override async Task OnInitializedAsync()
        {
            _isLoadingInitial = true;

            Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("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<ShiftWithTransport>();
                    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<List<CalendarInfo>>() ?? 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<Dictionary<DateTime, string>>() ?? new();
                    await ReloadTransportDataForSchedule();
                }
                else
                {
                    SelectedSchedule = new Dictionary<DateTime, string>();
                    SelectedShiftsWithTransport = new Dictionary<DateTime, ShiftWithTransport>();
                }
            }
            catch (Exception)
            {
                SelectedSchedule = new Dictionary<DateTime, string>();
                SelectedShiftsWithTransport = new Dictionary<DateTime, ShiftWithTransport>();
            }
        }

        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<ShiftWithTransport>();
                        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<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;

            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<string>();

            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<List<Shift>>("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:

<button class="btn export-btn" @onclick="SyncToNextcloud" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Nextcloud</button>

2b. Update the calendar selector modal title — change:

<h5 class="modal-title">Select Google Calendar</h5>

To:

<h5 class="modal-title">Select @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google") Calendar</h5>

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:

@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:

<p>Syncing with google calendar...</p>

To:

<p>Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...</p>
  • Step 3: Build
cd /home/schaad/Documents/ShiftScheduler && dotnet build

Expected: Build succeeds.

  • Step 4: Run all tests
cd /home/schaad/Documents/ShiftScheduler && dotnet test ShiftScheduler.Services.Tests/ -v minimal

Expected: All tests pass.

  • Step 5: Commit
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