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
@@ -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})";
}