feat: add Nextcloud sync button and update client to provider-agnostic API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-04-10 23:03:45 +02:00
parent dacd8bc082
commit 15bf1603b7
2 changed files with 52 additions and 76 deletions

View file

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

View file

@ -10,7 +10,7 @@ namespace ShiftScheduler.Client.Pages
[Inject] private HttpClient HttpClient { get; set; } = default!; [Inject] private HttpClient HttpClient { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!; [Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!;
private List<Shift> Shifts { get; set; } = new(); private List<Shift> Shifts { get; set; } = new();
private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new(); private Dictionary<DateTime, string> SelectedSchedule { get; set; } = new();
private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new(); private Dictionary<DateTime, ShiftWithTransport> SelectedShiftsWithTransport { get; set; } = new();
@ -21,6 +21,7 @@ namespace ShiftScheduler.Client.Pages
private bool _showConfigDialog = false; private bool _showConfigDialog = false;
private bool _isSyncing = false; private bool _isSyncing = false;
private bool _showCalendarSelector = false; private bool _showCalendarSelector = false;
private string _syncProvider = string.Empty;
private string _errorMessage = string.Empty; private string _errorMessage = string.Empty;
private string _successMessage = string.Empty; private string _successMessage = string.Empty;
private List<CalendarInfo> _availableCalendars = new(); private List<CalendarInfo> _availableCalendars = new();
@ -30,15 +31,12 @@ namespace ShiftScheduler.Client.Pages
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_isLoadingInitial = true; _isLoadingInitial = true;
// Load shifts first to show the calendar immediately
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new(); Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
// Show calendar immediately, don't wait for transport data
_isLoadingInitial = false; _isLoadingInitial = false;
StateHasChanged(); StateHasChanged();
// Load schedule and transport data in the background
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
await LoadScheduleFromStorage(); await LoadScheduleFromStorage();
@ -63,29 +61,25 @@ namespace ShiftScheduler.Client.Pages
private async Task SelectShift(DateTime day, string shiftName) private async Task SelectShift(DateTime day, string shiftName)
{ {
SelectedSchedule[day] = shiftName; SelectedSchedule[day] = shiftName;
// Get transport information for this shift and date - per day loading
_isLoadingTransportPerDay[day] = true; _isLoadingTransportPerDay[day] = true;
StateHasChanged(); StateHasChanged();
try try
{ {
var request = new { ShiftName = shiftName, Date = day }; var request = new { ShiftName = shiftName, Date = day };
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request); var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>(); var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
if (shiftWithTransport != null) if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport; SelectedShiftsWithTransport[day] = shiftWithTransport;
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error fetching transport data: {ex.Message}"); Console.WriteLine($"Error fetching transport data: {ex.Message}");
// Create a fallback with just the shift data
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName); var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null) if (shift != null)
{ {
@ -103,7 +97,7 @@ namespace ShiftScheduler.Client.Pages
_isLoadingTransportPerDay[day] = false; _isLoadingTransportPerDay[day] = false;
StateHasChanged(); StateHasChanged();
} }
await SaveScheduleToStorage(); await SaveScheduleToStorage();
} }
@ -117,7 +111,7 @@ namespace ShiftScheduler.Client.Pages
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month }; var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", request); var response = await HttpClient.PostAsJsonAsync("api/shift/export_ics", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var bytes = await response.Content.ReadAsByteArrayAsync(); var bytes = await response.Content.ReadAsByteArrayAsync();
@ -136,7 +130,7 @@ namespace ShiftScheduler.Client.Pages
{ {
_errorMessage = $"Error exporting ICS file: {ex.Message}"; _errorMessage = $"Error exporting ICS file: {ex.Message}";
} }
StateHasChanged(); StateHasChanged();
} }
@ -150,7 +144,7 @@ namespace ShiftScheduler.Client.Pages
var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month }; var request = new { Year = SelectedDate.Year, Month = SelectedDate.Month };
var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", request); var response = await HttpClient.PostAsJsonAsync("api/shift/export_pdf", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var bytes = await response.Content.ReadAsByteArrayAsync(); var bytes = await response.Content.ReadAsByteArrayAsync();
@ -168,38 +162,39 @@ namespace ShiftScheduler.Client.Pages
{ {
_errorMessage = $"Error exporting PDF file: {ex.Message}"; _errorMessage = $"Error exporting PDF file: {ex.Message}";
} }
StateHasChanged(); StateHasChanged();
} }
private async Task SyncToGoogleCalendar() private Task SyncToGoogleCalendar() => StartCalendarSync("google");
private Task SyncToNextcloud() => StartCalendarSync("nextcloud");
private async Task StartCalendarSync(string provider)
{ {
try try
{ {
_errorMessage = string.Empty; _errorMessage = string.Empty;
_successMessage = string.Empty; _successMessage = string.Empty;
_syncProvider = provider;
_isSyncing = true; _isSyncing = true;
StateHasChanged(); StateHasChanged();
// Get available calendars var response = await HttpClient.GetAsync($"api/shift/calendars?provider={provider}");
var response = await HttpClient.GetAsync("api/shift/google_calendars");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
_availableCalendars = await response.Content.ReadFromJsonAsync<List<CalendarInfo>>() ?? new(); _availableCalendars = await response.Content.ReadFromJsonAsync<List<CalendarInfo>>() ?? new();
if (_availableCalendars.Count == 1) if (_availableCalendars.Count == 1)
{ {
// If only one calendar, sync directly
await PerformCalendarSync(_availableCalendars[0].Id); await PerformCalendarSync(_availableCalendars[0].Id);
} }
else if (_availableCalendars.Count > 1) else if (_availableCalendars.Count > 1)
{ {
// Show calendar selector
_showCalendarSelector = true; _showCalendarSelector = true;
} }
else else
{ {
_errorMessage = "No writable calendars found in your Google account."; _errorMessage = "No writable calendars found.";
} }
} }
else else
@ -210,7 +205,7 @@ namespace ShiftScheduler.Client.Pages
} }
catch (Exception ex) catch (Exception ex)
{ {
_errorMessage = $"Error accessing Google Calendar: {ex.Message}"; _errorMessage = $"Error accessing calendar: {ex.Message}";
} }
finally finally
{ {
@ -234,27 +229,27 @@ namespace ShiftScheduler.Client.Pages
var request = new var request = new
{ {
Provider = _syncProvider,
CalendarId = calendarId, CalendarId = calendarId,
Year = SelectedDate.Year, Year = SelectedDate.Year,
Month = SelectedDate.Month Month = SelectedDate.Month
}; };
var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_google_calendar", request); var response = await HttpClient.PostAsJsonAsync("api/shift/sync_to_calendar", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var result = await response.Content.ReadFromJsonAsync<dynamic>(); _successMessage = "Shifts synced successfully!";
_successMessage = "Shifts synced to Google Calendar successfully!";
} }
else else
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_errorMessage = $"Failed to sync to Google Calendar: {response.StatusCode}. {errorContent}"; _errorMessage = $"Failed to sync: {response.StatusCode}. {errorContent}";
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_errorMessage = $"Error syncing to Google Calendar: {ex.Message}"; _errorMessage = $"Error syncing: {ex.Message}";
} }
finally finally
{ {
@ -280,7 +275,7 @@ namespace ShiftScheduler.Client.Pages
Month = SelectedDate.Month, Month = SelectedDate.Month,
Schedule = SelectedSchedule Schedule = SelectedSchedule
}; };
await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request); await HttpClient.PostAsJsonAsync("api/shift/save_schedule", request);
} }
catch (Exception) catch (Exception)
@ -294,12 +289,10 @@ namespace ShiftScheduler.Client.Pages
try try
{ {
var response = await HttpClient.GetAsync($"api/shift/load_schedule/{SelectedDate.Year}/{SelectedDate.Month}"); var response = await HttpClient.GetAsync($"api/shift/load_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new(); SelectedSchedule = await response.Content.ReadFromJsonAsync<Dictionary<DateTime, string>>() ?? new();
// Reload transport data for all selected shifts
await ReloadTransportDataForSchedule(); await ReloadTransportDataForSchedule();
} }
else else
@ -318,41 +311,33 @@ namespace ShiftScheduler.Client.Pages
private async Task ReloadTransportDataForSchedule() private async Task ReloadTransportDataForSchedule()
{ {
SelectedShiftsWithTransport.Clear(); SelectedShiftsWithTransport.Clear();
// Clear existing per-day loading states
_isLoadingTransportPerDay.Clear(); _isLoadingTransportPerDay.Clear();
// Mark all days as loading
foreach (var kvp in SelectedSchedule) foreach (var kvp in SelectedSchedule)
{
_isLoadingTransportPerDay[kvp.Key] = true; _isLoadingTransportPerDay[kvp.Key] = true;
}
StateHasChanged(); StateHasChanged();
// Create parallel tasks for all transport data requests
var transportTasks = SelectedSchedule.Select(async kvp => var transportTasks = SelectedSchedule.Select(async kvp =>
{ {
var day = kvp.Key; var day = kvp.Key;
var shiftName = kvp.Value; var shiftName = kvp.Value;
try try
{ {
var request = new { ShiftName = shiftName, Date = day }; var request = new { ShiftName = shiftName, Date = day };
var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request); var response = await HttpClient.PostAsJsonAsync("api/shift/shift_transport", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>(); var shiftWithTransport = await response.Content.ReadFromJsonAsync<ShiftWithTransport>();
if (shiftWithTransport != null) if (shiftWithTransport != null)
{
SelectedShiftsWithTransport[day] = shiftWithTransport; SelectedShiftsWithTransport[day] = shiftWithTransport;
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}"); Console.WriteLine($"Error reloading transport data for {day}: {ex.Message}");
// Create fallback without transport
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName); var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift != null) if (shift != null)
{ {
@ -367,13 +352,11 @@ namespace ShiftScheduler.Client.Pages
} }
finally finally
{ {
// Mark this day as no longer loading
_isLoadingTransportPerDay[day] = false; _isLoadingTransportPerDay[day] = false;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
}).ToArray(); }).ToArray();
// Wait for all transport data to load
await Task.WhenAll(transportTasks); await Task.WhenAll(transportTasks);
} }
@ -381,7 +364,7 @@ namespace ShiftScheduler.Client.Pages
{ {
SelectedSchedule.Clear(); SelectedSchedule.Clear();
SelectedShiftsWithTransport.Clear(); SelectedShiftsWithTransport.Clear();
try try
{ {
await HttpClient.DeleteAsync($"api/shift/delete_schedule/{SelectedDate.Year}/{SelectedDate.Month}"); await HttpClient.DeleteAsync($"api/shift/delete_schedule/{SelectedDate.Year}/{SelectedDate.Month}");
@ -392,45 +375,42 @@ namespace ShiftScheduler.Client.Pages
} }
} }
// Helper method to get transport summary for display
private string GetTransportSummary(DateTime date) private string GetTransportSummary(DateTime date)
{ {
if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport)) if (!SelectedShiftsWithTransport.TryGetValue(date, out var shiftWithTransport))
return string.Empty; return string.Empty;
var summaries = new List<string>(); var summaries = new List<string>();
var morningTransport = shiftWithTransport.GetMorningTransportSummary(); var morningTransport = shiftWithTransport.GetMorningTransportSummary();
if (!string.IsNullOrEmpty(morningTransport)) if (!string.IsNullOrEmpty(morningTransport))
summaries.Add(morningTransport); summaries.Add(morningTransport);
var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary(); var afternoonTransport = shiftWithTransport.GetAfternoonTransportSummary();
if (!string.IsNullOrEmpty(afternoonTransport)) if (!string.IsNullOrEmpty(afternoonTransport))
summaries.Add(afternoonTransport); summaries.Add(afternoonTransport);
if (summaries.Count == 0) { return string.Empty; } if (summaries.Count == 0) return string.Empty;
return "🚂 " + string.Join(" | ", summaries); return "🚂 " + string.Join(" | ", summaries);
} }
// Helper method to get shift times for display
private string GetShiftTimes(DateTime date) private string GetShiftTimes(DateTime date)
{ {
if (!SelectedSchedule.TryGetValue(date, out var shiftName)) if (!SelectedSchedule.TryGetValue(date, out var shiftName))
return string.Empty; return string.Empty;
var shift = Shifts.FirstOrDefault(s => s.Name == shiftName); var shift = Shifts.FirstOrDefault(s => s.Name == shiftName);
if (shift == null) if (shift == null) return string.Empty;
return string.Empty;
var times = new List<string>(); var times = new List<string>();
if (!string.IsNullOrEmpty(shift.MorningTime)) if (!string.IsNullOrEmpty(shift.MorningTime))
times.Add($"🌅 {shift.MorningTime}"); times.Add($"🌅 {shift.MorningTime}");
if (!string.IsNullOrEmpty(shift.AfternoonTime)) if (!string.IsNullOrEmpty(shift.AfternoonTime))
times.Add($"🌆 {shift.AfternoonTime}"); times.Add($"🌆 {shift.AfternoonTime}");
return string.Join(" | ", times); return string.Join(" | ", times);
} }
@ -448,9 +428,11 @@ namespace ShiftScheduler.Client.Pages
private async Task OnConfigurationChanged() private async Task OnConfigurationChanged()
{ {
// Reload shifts after configuration changes
Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new(); Shifts = await HttpClient.GetFromJsonAsync<List<Shift>>("api/shift/shifts") ?? new();
StateHasChanged(); StateHasChanged();
} }
private bool IsLoadingTransportForDay(DateTime day) =>
_isLoadingTransportPerDay.GetValueOrDefault(day, false);
} }
} }