WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle

Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen
WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry.
Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für
Stunden auf, die real unverändert sind — wird in einem Folge-Commit
untersucht, deshalb vorerst auf diesem Branch statt main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:30:15 +02:00
co-authored by Claude Sonnet 5
parent e65a729f97
commit 4eb4d0a946
30 changed files with 3333 additions and 8 deletions
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using System.Collections.ObjectModel;
@@ -62,6 +63,8 @@ public partial class TimetableViewModel : ObservableObject
private readonly SchoolYearService _schoolYear;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IUntisSlotMappingRepository _untisMappings;
private readonly WebUntisSettingsService _untisSettings;
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
@@ -86,6 +89,13 @@ public partial class TimetableViewModel : ObservableObject
[ObservableProperty] private int _weekOffset;
public bool IsCurrentWeek => WeekOffset == 0;
// ── WebUntis-Abweichung (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt") ──────
// Kein erneuter iCal-Abruf hier - vergleicht nur den lokal bereits bestätigten
// Zuordnungsstand (UntisSlotMapping, siehe UntisMappingReviewDialog) gegen die aktuellen
// TimetableSlots. Bleibt komplett verborgen, solange der Abgleich nicht aktiviert ist.
[ObservableProperty] private bool _hasUntisMismatch;
[ObservableProperty] private string _untisMismatchLabel = "";
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; }
@@ -97,12 +107,14 @@ public partial class TimetableViewModel : ObservableObject
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions)
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings)
{
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
_untisMappings = untisMappings; _untisSettings = untisSettings;
Load();
}
@@ -143,8 +155,33 @@ public partial class TimetableViewModel : ObservableObject
BuildToday(today);
BuildHoursWarnings();
BuildUpcomingExams(today);
LoadUntisMismatch();
}
private void LoadUntisMismatch()
{
if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; }
// CoveredPeriods statt nur PeriodNumber: bei einer von WebUntis zu einem Termin
// zusammengefassten Doppelstunde bestätigt eine einzige Zuordnung mehrere TimetableSlots
// auf einmal (siehe UntisSlotMapping.CoveredPeriods-Dokumentation).
var confirmedKeys = _untisMappings.GetAll()
.Where(m => m.Confirmed && m.Kind == SubstitutionKind.Lesson && m.GroupId is not null)
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
.Select(period => (m.Weekday, period, GroupId: m.GroupId!.Value)))
.ToHashSet();
var mismatchCount = _slots.GetAll()
.Count(s => !confirmedKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)));
HasUntisMismatch = mismatchCount > 0;
UntisMismatchLabel = mismatchCount == 1
? "1 Stundenplan-Eintrag ohne bestätigte WebUntis-Zuordnung."
: $"{mismatchCount} Stundenplan-Einträge ohne bestätigte WebUntis-Zuordnung.";
}
[RelayCommand]
private void ReviewUntisMismatch() => OnNavigateToSettings?.Invoke(SettingsTab.WebUntis);
// ── Anstehende Klausurtermine (4.4.3) ────────────────────────────────────
/// <summary>
@@ -0,0 +1,188 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning;
/// <summary>
/// Zeigt die erkannten regulären WebUntis-Wochenmuster und lässt den Nutzer die vorgeschlagene
/// Lerngruppe je Muster bestätigen oder ändern, bevor der laufende Abgleich (UntisSyncService)
/// automatisch Vertretungen dafür schreibt (Nutzer-Feedback: die erstmalige Zuordnung ist
/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine
/// Bestätigung). Termine ohne Klassenbezug (Aufsichten/Springstunden, Nutzer-Feedback: "Zwei
/// Termine sind meine Aufsichten, die nicht zugeordnet werden können") lassen sich stattdessen per
/// Checkbox als Aufsicht bestätigen. Zusätzlich informativ: Stundenplan-Einträge, zu denen kein
/// WebUntis-Muster mehr passt ("Stundenplan passt nicht mehr").
///
/// Beim erneuten Öffnen werden bereits bestätigte Zuordnungen aus dem Repository vorbefüllt
/// (Nutzer-Feedback: "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" — die
/// Auswahl wurde zwar gespeichert, beim nächsten Öffnen aber von der frischen Musterkennung
/// überschrieben, was wie ein Datenverlust wirkte) und beim Speichern per vorhandener Id
/// aktualisiert statt dupliziert (siehe UntisMappingRow.BuildMapping).
/// </summary>
public partial class UntisMappingReviewDialogViewModel : ObservableObject
{
private readonly UntisSyncService _untisSync;
private readonly IUntisSlotMappingRepository _mappings;
/// Gruppenname + Fach zur Auswahl (Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das
/// Fach dabei, kann ich nicht sicher die richtige Lerngruppe hier auswählen") — dieselbe
/// Namenskonflikt-Begründung wie in TimetableSlotDialogViewModel, hier über eine kleine
/// Anzeige-Hülle statt eines Label-Strings, da dieser Dialog direkt an LearningGroup-Objekte
/// bindet.
public List<UntisGroupOption> Groups { get; }
public ObservableCollection<UntisMappingRow> Rows { get; } = [];
public ObservableCollection<string> UnmatchedSlotLabels { get; } = [];
public bool HasUnmatchedSlots => UnmatchedSlotLabels.Count > 0;
public bool HasNoRows => !IsLoading && Rows.Count == 0;
[ObservableProperty] private bool _isLoading = true;
[ObservableProperty] private string _errorMessage = "";
public bool Result { get; private set; }
public UntisMappingReviewDialogViewModel(UntisSyncService untisSync, IUntisSlotMappingRepository mappings,
List<LearningGroup> groups, ISubjectRepository subjects)
{
_untisSync = untisSync;
_mappings = mappings;
var subjectNames = subjects.GetAll().ToDictionary(s => s.Id, s => s.Name);
Groups = groups.OrderBy(g => g.Name)
.Select(g => new UntisGroupOption(g, g.SubjectId is { } sid ? subjectNames.GetValueOrDefault(sid) : null))
.ToList();
}
public async Task LoadAsync()
{
IsLoading = true;
ErrorMessage = "";
try
{
var preview = await _untisSync.FetchAndBuildMatchPreviewAsync();
Rows.Clear();
UnmatchedSlotLabels.Clear();
if (preview is null) { ErrorMessage = "Keine iCal-URL hinterlegt."; return; }
// Bei mehreren Mappings für denselben Slot (z.B. Altdaten vor dem Bugfix) gewinnt das
// zuletzt angelegte - gleiche Regel wie UntisDiffService.
var existingByKey = _mappings.GetAll()
.GroupBy(m => (m.Weekday, m.StartTime))
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
foreach (var match in preview.Matches.Matches.OrderBy(m => m.Pattern.Weekday).ThenBy(m => m.Pattern.StartTime))
{
existingByKey.TryGetValue((match.Pattern.Weekday, match.Pattern.StartTime), out var existing);
Rows.Add(new UntisMappingRow(match, Groups, existing));
}
foreach (var slot in preview.Matches.UnmatchedTimetableSlots)
UnmatchedSlotLabels.Add($"{WeekdayLabel(slot.Weekday)}, {slot.PeriodNumber}. Stunde");
OnPropertyChanged(nameof(HasUnmatchedSlots));
}
catch (Exception ex) { ErrorMessage = $"Abruf fehlgeschlagen: {ex.Message}"; }
finally { IsLoading = false; OnPropertyChanged(nameof(HasNoRows)); }
}
[RelayCommand]
private void Save()
{
var confirmed = Rows.Select(r => r.BuildMapping()).Where(m => m is not null).Select(m => m!);
_untisSync.ConfirmMappings(confirmed);
Result = true;
}
private static string WeekdayLabel(DayOfWeek weekday) => weekday switch
{
DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag", DayOfWeek.Saturday => "Samstag", DayOfWeek.Sunday => "Sonntag",
_ => weekday.ToString(),
};
}
public partial class UntisMappingRow : ObservableObject
{
private readonly Guid? _existingMappingId;
public UntisSlotMatch Match { get; }
public string WeekdayLabel { get; }
public string TimeLabel { get; }
public string PatternLabel { get; }
public bool IsConfident => Match.IsConfident;
public bool IsSupervisionCandidate => Match.IsSupervisionCandidate;
public bool CanResolve => IsSupervisionCandidate ? Match.AfterPeriod is not null : Match.PeriodNumber is not null;
[ObservableProperty] private UntisGroupOption? _selectedGroup;
[ObservableProperty] private bool _confirmAsSupervision;
public UntisMappingRow(UntisSlotMatch match, List<UntisGroupOption> groups, UntisSlotMapping? existing)
{
Match = match;
_existingMappingId = existing?.Id;
WeekdayLabel = match.Pattern.Weekday switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr",
DayOfWeek.Saturday => "Sa", _ => "So",
};
TimeLabel = IsSupervisionCandidate
? $"{match.Pattern.StartTime:HH:mm}{match.Pattern.EndTime:HH:mm} (Pause nach Stunde {match.AfterPeriod})"
: match.CoveredPeriods.Count > 1
? $"{match.CoveredPeriods[0]}.{match.CoveredPeriods[^1]}. Stunde ({match.Pattern.StartTime:HH:mm}, Doppelstunde)"
: match.PeriodNumber is { } period
? $"{period}. Stunde ({match.Pattern.StartTime:HH:mm})"
: $"{match.Pattern.StartTime:HH:mm} (keine passende Stunde im Stundenraster)";
PatternLabel = IsSupervisionCandidate
? "Aufsicht / Springstunde (kein Klassenbezug)"
: match.Pattern.ClassTokens.Count == 0
? (match.Pattern.Summary ?? "(ohne Fach)")
: $"{match.Pattern.Summary ?? "?"} · {string.Join(", ", match.Pattern.ClassTokens)}";
if (IsSupervisionCandidate)
{
_confirmAsSupervision = existing is { Confirmed: true, Kind: SubstitutionKind.Supervision };
}
else
{
_selectedGroup = existing is { Confirmed: true, Kind: SubstitutionKind.Lesson, GroupId: { } existingGroupId }
? groups.FirstOrDefault(g => g.Group.Id == existingGroupId)
: match.SuggestedGroupId is { } suggestedId ? groups.FirstOrDefault(g => g.Group.Id == suggestedId) : null;
}
}
/// null, wenn diese Zeile nicht (mehr) bestätigt ist — wird beim Speichern übersprungen.
internal UntisSlotMapping? BuildMapping()
{
if (IsSupervisionCandidate)
{
if (!ConfirmAsSupervision || Match.AfterPeriod is null) return null;
return new UntisSlotMapping
{
Id = _existingMappingId ?? Guid.NewGuid(),
Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime,
Summary = Match.Pattern.Summary, ClassToken = "",
Kind = SubstitutionKind.Supervision, AfterPeriod = Match.AfterPeriod,
Confirmed = true,
};
}
if (SelectedGroup is null || Match.PeriodNumber is null) return null;
return new UntisSlotMapping
{
Id = _existingMappingId ?? Guid.NewGuid(),
Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime,
Summary = Match.Pattern.Summary, ClassToken = string.Join(";", Match.Pattern.ClassTokens),
Kind = SubstitutionKind.Lesson, GroupId = SelectedGroup.Group.Id, PeriodNumber = Match.PeriodNumber,
CoveredPeriods = Match.CoveredPeriods, Confirmed = true,
};
}
}
public sealed class UntisGroupOption(LearningGroup group, string? subjectName)
{
public LearningGroup Group { get; } = group;
public string DisplayLabel { get; } = string.IsNullOrEmpty(subjectName) ? group.Name : $"{group.Name} ({subjectName})";
}
@@ -36,8 +36,9 @@ public enum SettingsTab
Privacy = 10,
Sync = 11,
Ai = 12,
Appearance = 13,
Trash = 14,
WebUntis = 13,
Appearance = 14,
Trash = 15,
}
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
@@ -191,6 +192,16 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _aiIsLoggedIn;
[ObservableProperty] private string _aiBalanceDisplay = "";
// ── WebUntis-iCal-Abgleich (Nutzer-Feedback) ──────────────────────────────
[ObservableProperty] private bool _untisEnabled;
[ObservableProperty] private bool _untisIsConfigured;
[ObservableProperty] private string _untisIcalUrlInput = "";
[ObservableProperty] private string _untisUrlError = "";
[ObservableProperty] private string _untisStatusDisplay = "";
[ObservableProperty] private bool _untisFetchBusy;
public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
[ObservableProperty] private string _syncServerUrl = "";
@@ -268,6 +279,8 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings;
private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly UntisSyncService? _untisSync;
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
@@ -289,10 +302,12 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null)
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -320,6 +335,8 @@ public partial class SettingsViewModel : ObservableObject
_letterTemplates = letterTemplates;
_aiSettings = aiSettings;
_aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisSync = untisSync;
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_eventQueue = eventQueue;
@@ -342,6 +359,7 @@ public partial class SettingsViewModel : ObservableObject
LoadSupervisionDuties();
LoadLetterTemplates();
LoadAiSettings();
LoadUntisSettings();
LoadSyncSettings();
LoadSyncConflicts();
}
@@ -485,6 +503,60 @@ public partial class SettingsViewModel : ObservableObject
AiBalanceDisplay = "";
}
// ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ────
//
// Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab
// (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen).
private void LoadUntisSettings()
{
UntisEnabled = _untisSettings.Enabled;
UntisIsConfigured = _untisSettings.IsConfigured;
UntisStatusDisplay = _untisSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
}
[RelayCommand]
private void UntisSaveUrl()
{
UntisUrlError = "";
if (string.IsNullOrWhiteSpace(UntisIcalUrlInput)) { UntisUrlError = "iCal-URL erforderlich."; return; }
if (!Uri.TryCreate(UntisIcalUrlInput, UriKind.Absolute, out _)) { UntisUrlError = "Ungültige URL."; return; }
_untisSettings.SetIcalUrl(UntisIcalUrlInput.Trim());
_untisSettings.SetEnabled(true);
UntisIcalUrlInput = "";
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void UntisRemove()
{
_untisSettings.ClearIcalUrl();
LoadUntisSettings();
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private async Task UntisFetchNow()
{
if (_untisSync is null) { UntisStatusDisplay = "Abgleich nicht aktiv — App neu starten."; return; }
UntisFetchBusy = true;
try
{
await _untisSync.PollAsync();
LoadUntisSettings();
}
finally { UntisFetchBusy = false; }
}
[RelayCommand]
private async Task UntisReviewMapping()
{
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
}
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
//
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen