From 960d52686ada9d190391da6e22474388e26af087 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Fri, 25 Sep 2026 11:54:51 +0200 Subject: [PATCH] Stundenplan und Untis-Namensabgleich verbessern --- .../TimetableViewModelTests.cs | 38 +++++++++++++++++++ .../UntisNameMatchingTests.cs | 16 +++----- .../ClassTeacher/UntisNameMatching.cs | 5 ++- .../WebUntisHomeworkComparisonViewModel.cs | 15 +++----- ...ebUntisLessonAbsenceComparisonViewModel.cs | 14 ++----- .../ViewModels/Planning/TimetableViewModel.cs | 32 +++++++++++++--- ...ebUntisDocumentationComparisonViewModel.cs | 15 +++----- .../Views/Planning/TimetableView.axaml | 14 +++++-- 8 files changed, 98 insertions(+), 51 deletions(-) diff --git a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs index 399c869..c792553 100644 --- a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs @@ -64,6 +64,44 @@ public sealed class TimetableViewModelTests Assert.Equal(6 + 10 * 6, vm.WeekItems.Count); } + [Fact] + public void Load_MarkiertDieAktuelleStundeAusDemHinterlegtenStundenraster() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-currentperiod-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + var schedule = new PeriodScheduleService(tempPath); + schedule.SetPeriods([ + new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }, + new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 0), End = new TimeOnly(9, 45) }, + ]); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), periodSchedule: schedule); + vm.Clock = () => new DateTime(2026, 9, 21, 9, 15, 0); // Montag, 3. Stunde + + vm.Load(); + + var periodLabels = vm.WeekItems.Where(x => x.IsPeriodLabel).ToList(); + Assert.True(periodLabels.Single(x => x.PeriodNumber == 3).IsCurrentPeriod); + Assert.DoesNotContain(periodLabels, x => x.PeriodNumber != 3 && x.IsCurrentPeriod); + } + + [Fact] + public void Load_InAndererKalenderwoche_MarkiertKeineAktuelleStunde() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-currentperiod-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + var schedule = new PeriodScheduleService(tempPath); + schedule.SetPeriods([ + new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 0), End = new TimeOnly(9, 45) }, + ]); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), periodSchedule: schedule); + vm.Clock = () => new DateTime(2026, 9, 21, 9, 15, 0); + vm.WeekOffset = 1; + + vm.Load(); + + Assert.DoesNotContain(vm.WeekItems, x => x.IsCurrentPeriod); + } + [Fact] public void Load_ZeigtZugewieseneGruppeInDerPassendenZelle() { diff --git a/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs b/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs index 8ed6059..ccee2b6 100644 --- a/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs +++ b/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs @@ -9,6 +9,8 @@ public sealed class UntisNameMatchingTests [InlineData("Ben Schmidt", "Schmidt Ben")] [InlineData("Ada Müller", "MÜLLER ADA")] [InlineData("Max Mustermann", "mustermann max")] + [InlineData("Gerste, Amelia", "Gerste Amelia")] + [InlineData("Müller-Ada", "Ada Müller")] public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) => Assert.True(UntisNameMatching.NamesMatch(a, b)); @@ -19,17 +21,11 @@ public sealed class UntisNameMatchingTests public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) => Assert.False(UntisNameMatching.NamesMatch(a, b)); - // Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke. - // Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben - // ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt - // fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer - // bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne - // Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService - // und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden. + // Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma), während WebUntis + // denselben Namen je nach Bericht ohne Komma und in wechselnder Reihenfolge liefert. [Fact] - public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung() + public void NamesMatch_KommaGetrennterAnzeigename_PasstZumUntisNamenOhneKomma() { - Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia")); - Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia")); + Assert.True(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia")); } } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs index 8da87d6..6275acc 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs @@ -16,7 +16,8 @@ public static class UntisNameMatching !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && NameKey(a) == NameKey(b); public static string NameKey(string value) => string.Join(' ', - value.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(w => w.Trim().ToLowerInvariant()) + System.Text.RegularExpressions.Regex.Split(value, @"[^\p{L}\p{Nd}]+") + .Where(w => w.Length > 0) + .Select(w => w.ToLowerInvariant()) .OrderBy(w => w, StringComparer.Ordinal)); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs index 73f303b..072ef17 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs @@ -6,6 +6,7 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.ClassTeacher; namespace LehrerApp.Desktop.ViewModels.Groups; @@ -130,7 +131,7 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject foreach (var (entry, date) in ordered) { - var nameKey = NameKey(entry.StudentName); + var nameKey = UntisNameMatching.NameKey(entry.StudentName); var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex) ? classIndex.GetValueOrDefault(nameKey) : null) @@ -209,17 +210,11 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject return null; } - private static string NameKey(string value) => value.Trim().ToLowerInvariant(); - - // Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls - // innerhalb der Kandidaten eindeutig. + // Wie bei den übrigen WebUntis-Abgleichen: Reihenfolge und Satzzeichen werden normalisiert, + // aber nur innerhalb der Kandidaten eindeutige Namen automatisch zugeordnet. private static Dictionary BuildNameIndex(IReadOnlyList candidates) => candidates - .SelectMany(student => new[] - { - NameKey($"{student.LastName} {student.FirstName}"), - NameKey($"{student.FirstName} {student.LastName}"), - }.Select(key => (Key: key, Student: student))) + .Select(student => (Key: UntisNameMatching.NameKey(student.FullName), Student: student)) .GroupBy(x => x.Key) .Where(group => group.Select(x => x.Student).Distinct().Count() == 1) .ToDictionary(group => group.Key, group => group.First().Student); diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs index f490dec..1e84bcc 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs @@ -5,6 +5,7 @@ using LehrerApp.Core.AiPlanning; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.ClassTeacher; namespace LehrerApp.Desktop.ViewModels.Groups; @@ -122,11 +123,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student))) .Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student); var byName = courseStudents - .SelectMany(student => new[] - { - NameKey($"{student.LastName} {student.FirstName}"), - NameKey($"{student.FirstName} {student.LastName}"), - }.Select(key => (Key: key, Student: student))) + .Select(student => (Key: UntisNameMatching.NameKey(student.FullName), Student: student)) .GroupBy(x => x.Key) .Where(group => group.Select(x => x.Student).Distinct().Count() == 1) .ToDictionary(group => group.Key, group => group.First().Student); @@ -155,7 +152,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject { var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent) ? byKeyStudent - : byName.GetValueOrDefault(NameKey(absence.StudentName)); + : byName.GetValueOrDefault(UntisNameMatching.NameKey(absence.StudentName)); var targetStatus = MapStatus(absence); var row = new WebUntisLessonAbsenceRow { @@ -281,11 +278,6 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject return filled; } - // Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht - // dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen - // werden beim Aufbau von `byName` registriert; hier wird nur normalisiert. - private static string NameKey(string value) => value.Trim().ToLowerInvariant(); - // MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit // UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das // aus dem Tritt geraten könnte. diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index 17578c5..d15cc23 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -164,7 +164,8 @@ public partial class TimetableViewModel : ObservableObject public void Load() { - var today = DateOnly.FromDateTime(Clock()); + var now = Clock(); + var today = DateOnly.FromDateTime(now); TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE")); var publicHolidayDates = new HashSet(); @@ -176,7 +177,7 @@ public partial class TimetableViewModel : ObservableObject var duties = _supervisionDuties.GetAll(); BuildGrid(holidayBadges, duties); - BuildWeekOverview(today, publicHolidayDates, duties); + BuildWeekOverview(today, now, publicHolidayDates, duties); ApplyWeatherToWeekHeaders(today); _ = LoadWeatherAsync(today); BuildToday(today); @@ -436,13 +437,17 @@ public partial class TimetableViewModel : ObservableObject /// Vertretungsaufsicht) werden zwischen den betroffenen Stundenzeilen eingefügt, Vertretungsstunden /// ersetzen für ihre Stunde die sonst aus dem Stundenplan abgeleitete Anzeige. /// - private void BuildWeekOverview(DateOnly today, HashSet publicHolidayDates, List duties) + private void BuildWeekOverview(DateOnly today, DateTime now, HashSet publicHolidayDates, + List duties) { WeekItems.Clear(); var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7); var monday = currentWeekMonday.AddDays(WeekOffset * 7); var friday = monday.AddDays(4); WeekRangeLabel = $"{monday:dd.MM.} – {friday:dd.MM.yyyy}"; + var currentPeriod = monday == currentWeekMonday && Weekdays.Contains(today.DayOfWeek) + ? CurrentPeriodAt(TimeOnly.FromDateTime(now)) + : null; var allSlots = _slots.GetAll(); var groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id); @@ -459,7 +464,7 @@ public partial class TimetableViewModel : ObservableObject for (var period = FirstPeriod; period <= LastPeriod; period++) { - WeekItems.Add(WeekCellItem.PeriodLabel(period)); + WeekItems.Add(WeekCellItem.PeriodLabel(period, period == currentPeriod)); foreach (var weekday in Weekdays) { var date = dateByWeekday[weekday]; @@ -518,6 +523,19 @@ public partial class TimetableViewModel : ObservableObject WeekRows.Add(new WeekRowItem(rowCells)); } + private int? CurrentPeriodAt(TimeOnly time) + { + for (var period = FirstPeriod; period <= LastPeriod; period++) + { + if (_periodSchedule.GetTimes(period) is not { } times) continue; + var within = times.End > times.Start + ? time >= times.Start && time < times.End + : times.End < times.Start && (time >= times.Start || time < times.End); + if (within) return period; + } + return null; + } + private void AddWeekSupervisionRowIfAny(int afterPeriod, ILookup dutiesByPeriod, List substitutionsThisWeek, Dictionary dateByWeekday) { @@ -845,6 +863,7 @@ public partial class WeekCellItem : ObservableObject public DayOfWeek? Weekday { get; private init; } public int PeriodNumber { get; private init; } public bool IsToday { get; private init; } + public bool IsCurrentPeriod { get; private init; } public DateOnly? Date { get; private init; } public Guid GroupId { get; private init; } public string SubjectLabel { get; private init; } = ""; @@ -908,7 +927,10 @@ public partial class WeekCellItem : ObservableObject }) + $" {date:dd.MM.}", }; - public static WeekCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() }; + public static WeekCellItem PeriodLabel(int period, bool isCurrentPeriod = false) => new() + { + IsPeriodLabel = true, PeriodNumber = period, IsCurrentPeriod = isCurrentPeriod, Text = period.ToString(), + }; public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period }; diff --git a/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs index e43fcd9..430a36c 100644 --- a/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/WebUntisDocumentationComparisonViewModel.cs @@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.ClassTeacher; namespace LehrerApp.Desktop.ViewModels.Students; @@ -108,7 +109,7 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject foreach (var (entry, date) in ordered) { - var nameKey = NameKey(entry.StudentName); + var nameKey = UntisNameMatching.NameKey(entry.StudentName); var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex) ? classIndex.GetValueOrDefault(nameKey) : null) @@ -172,17 +173,11 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject Status = $"{selected.Count} Einträge aus WebUntis übernommen."; } - private static string NameKey(string value) => value.Trim().ToLowerInvariant(); - - // Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge, - // deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig. + // Die gemeinsame Untis-Normalisierung ignoriert Reihenfolge, Komma und abweichende Leerzeichen. + // Mehrdeutige Namen bleiben weiterhin bewusst ohne automatische Vorauswahl. private static Dictionary BuildNameIndex(IReadOnlyList candidates) => candidates - .SelectMany(student => new[] - { - NameKey($"{student.LastName} {student.FirstName}"), - NameKey($"{student.FirstName} {student.LastName}"), - }.Select(key => (Key: key, Student: student))) + .Select(student => (Key: UntisNameMatching.NameKey(student.FullName), Student: student)) .GroupBy(x => x.Key) .Where(group => group.Select(x => x.Student).Distinct().Count() == 1) .ToDictionary(group => group.Key, group => group.First().Student); diff --git a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml index 10f69fe..8e49345 100644 --- a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml +++ b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml @@ -15,6 +15,12 @@ + + @@ -229,9 +235,11 @@ - + + +