diff --git a/LehrerApp.Core/Services/UntisDiffService.cs b/LehrerApp.Core/Services/UntisDiffService.cs index 86c070d..d05fbb6 100644 --- a/LehrerApp.Core/Services/UntisDiffService.cs +++ b/LehrerApp.Core/Services/UntisDiffService.cs @@ -12,6 +12,15 @@ public sealed class UntisDiffResult /// Verschwundene, bereits als Ausfall verarbeitete Zeilen — werden aus dem Snapshot entfernt, /// damit derselbe Ausfall nicht bei jedem weiteren Poll erneut erkannt wird. public List SnapshotIdsToDelete { get; init; } = []; + /// ExternalIds automatisch erzeugter SubstitutionEntry-Zeilen, die NICHT (mehr) gebraucht + /// werden — z.B. weil eine früher erkannte Abweichung sich (durch einen Bugfix oder eine + /// erneute Zuordnung) als nicht mehr abweichend herausstellt, oder eine zuvor als fehlend + /// gemeldete Stunde jetzt wieder im Feed auftaucht. Nutzer-Feedback: "Es ist immer noch so" + /// (nachdem der eigentliche Vergleichsfehler bereits behoben war) — Ursache: einmal erzeugte + /// automatische Einträge wurden nie wieder entfernt, selbst wenn der Vergleich sie beim + /// nächsten Poll nicht mehr als Abweichung einstufte; sie blieben als Karteileichen stehen. + /// Existiert kein Eintrag mit dieser ExternalId, ist das Löschen ein no-op (siehe UntisSyncService). + public List SubstitutionExternalIdsToDelete { get; init; } = []; } /// @@ -46,6 +55,7 @@ public class UntisDiffService var substitutions = new List(); var snapshotToSave = new List(); + var externalIdsToDelete = new List(); var newEventsByUid = newEvents.ToDictionary(e => e.Uid); foreach (var evt in newEvents) @@ -70,8 +80,10 @@ public class UntisDiffService if (mapping.Kind == SubstitutionKind.Lesson) { - if (HasDeviated(evt, mapping)) - substitutions.Add(BuildChangedLesson(evt, mapping)); + if (DeviationReason(evt, mapping) is { } reason) + substitutions.Add(BuildChangedLesson(evt, mapping, reason)); + else + externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt } else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value))) { @@ -81,6 +93,10 @@ public class UntisDiffService // davon. substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Zusätzliche Aufsicht laut WebUntis.")); } + else + { + externalIdsToDelete.Add(evt.Uid); // jetzt als reguläre Aufsicht erkannt, keine Meldung mehr nötig + } } // Aufsichten: weiterhin rein reaktiv gegen den letzten Snapshot (kein fester wöchentlicher @@ -123,7 +139,13 @@ public class UntisDiffService { if (date.DayOfWeek != mapping.Weekday) continue; if (freeDateSet.Contains(date)) continue; - if (eventDates.Contains((date, mapping.StartTime))) continue; + if (eventDates.Contains((date, mapping.StartTime))) + { + // War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt + // wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab. + externalIdsToDelete.Add(BuildMissingExternalId(date, mapping)); + continue; + } substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping))); } @@ -133,7 +155,7 @@ public class UntisDiffService return new UntisDiffResult { SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave, - SnapshotIdsToDelete = snapshotIdsToDelete, + SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete, }; } @@ -141,12 +163,31 @@ public class UntisDiffService // manuelle Verknüpfung ist aber jetzt scheinbar vergessen" — Ursache: ClassToken wird beim // Bestätigen kompakt ohne Leerzeichen gespeichert ("10a;10b;10c"), WebUntis trennt die // Klassen in DESCRIPTION aber mit "; " (Semikolon + Leerzeichen), z.B. "10a; 10b; 10c; - // Gastro HED" — ein reiner Teilstring-Vergleich schlug für JEDE kombinierte/differenzierte - // Gruppe (mehr als ein Klassen-Token) strukturell fehl, unabhängig von der tatsächlich - // gewählten Gruppe. Fix: beide Seiten vor dem Vergleich von Leerzeichen befreien (gleiches - // Prinzip wie UntisMatchingService.Normalize für den Gruppennamen-Abgleich). - private static bool HasDeviated(UntisIcsEvent evt, UntisSlotMapping mapping) => - evt.Summary != mapping.Summary || !RemoveWhitespace(evt.Description).Contains(mapping.ClassToken); + // Gastro HED". Ein erster Fix entfernte Leerzeichen nur auf der evt.Description-Seite — blieb + // aber weiterhin falsch, wenn mapping.ClassToken selbst ein eingebettetes Leerzeichen enthält + // (z.B. wenn die reale DESCRIPTION mehrere Klassen NICHT mit ";", sondern mit "," trennt: + // ExtractClassTokens fand dann kein ";" und behandelte "10a, 10b, 10c" als EIN Token mit + // Leerzeichen/Kommas statt drei getrennte - ein Vergleich gegen die leerzeichenbereinigte + // Description konnte dieses Token dann NIE mehr finden, unabhängig von der Auswahl im Dialog). + // Fix: beide Seiten symmetrisch von Leerzeichen befreien (gleiches Prinzip wie + // UntisMatchingService.Normalize für den Gruppennamen-Abgleich), plus ExtractClassTokens + // akzeptiert jetzt sowohl ";" als auch "," als Trennzeichen zwischen Klassen. + // + // Trotzdem meldet der Nutzer weiterhin falsche Vertretungen für genau diese Art Muster + // ("Immer die Kurse mit Lerngruppen, die aus mehreren Klassen zusammengesetzt sind") - der + // genaue Grund lässt sich ohne Einblick in die echten DESCRIPTION-Werte aus seinem Feed nicht + // mehr blind erraten. Statt eines weiteren ungetesteten Rateversuchs schreibt eine abweichende + // Vertretung deshalb jetzt den GENAUEN Vergleich (roher evt.Summary/evt.Description gegen den + // gespeicherten mapping.Summary/ClassToken) in ihre eigene Beschreibung - der Nutzer kann die + // Ursache dann direkt im Stundenplan ablesen, ohne die iCal-URL erneut teilen zu müssen. + private static string? DeviationReason(UntisIcsEvent evt, UntisSlotMapping mapping) + { + if (evt.Summary != mapping.Summary) + return $"Fach weicht ab: WebUntis meldet „{evt.Summary ?? "(leer)"}“, erwartet war „{mapping.Summary ?? "(leer)"}“."; + if (!RemoveWhitespace(evt.Description).Contains(RemoveWhitespace(mapping.ClassToken))) + return $"Klasse weicht ab: WebUntis-Beschreibung „{evt.Description}“ enthält nicht den erwarteten Klassen-Token „{mapping.ClassToken}“."; + return null; + } private static string RemoveWhitespace(string value) => new(value.Where(c => !char.IsWhiteSpace(c)).ToArray()); @@ -157,11 +198,11 @@ public class UntisDiffService Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId, }; - private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping) => new() + private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new() { Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber, GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken, - Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"})", + Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}", ExternalId = evt.Uid, }; diff --git a/LehrerApp.Core/Services/UntisMatchingService.cs b/LehrerApp.Core/Services/UntisMatchingService.cs index 3a52b6f..f3acd6f 100644 --- a/LehrerApp.Core/Services/UntisMatchingService.cs +++ b/LehrerApp.Core/Services/UntisMatchingService.cs @@ -148,13 +148,19 @@ public class UntisMatchingService } // "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt). + // Trennzeichen zwischen mehreren Klassen wurde nur an einem einzelnen echten Beispiel mit EINER + // Klasse verifiziert (siehe Planungsdokument) - für kombinierte/differenzierte Gruppen ist nicht + // sicher belegt, ob WebUntis ";" oder "," verwendet, deshalb werden beide akzeptiert statt sich + // auf eine Annahme festzulegen. + private static readonly char[] ClassTokenSeparators = [';', ',']; + private static List ExtractClassTokens(string description, string? teacherToken) { var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken) ? description[..^teacherToken.Length].TrimEnd() : description; if (string.IsNullOrWhiteSpace(withoutTeacher)) return []; - return withoutTeacher.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + return withoutTeacher.Split(ClassTokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); } private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule) diff --git a/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs index 6e0ae3c..7bef6ab 100644 --- a/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs +++ b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs @@ -85,6 +85,40 @@ public sealed class UntisSyncServiceTests Assert.Single(substitutions.GetAll()); } + // ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus mehreren + // Klassen zusammengesetzt sind" — nachdem der eigentliche Vergleichsfehler (ClassToken/ + // Trennzeichen) behoben war, blieben bereits erzeugte falsche Vertretungen trotzdem stehen: + // ProcessIcsText schrieb bislang nur NEUE/weiterhin abweichende Kandidaten, räumte aber nie + // eine zuvor automatisch erzeugte Zeile ab, sobald der nächste Poll gar keine Abweichung mehr + // fand. Der Nutzer sah dadurch dieselbe (jetzt stale) Karteileiche, egal wie oft er neu + // zuordnete oder pollte. + + [Fact] + public void ProcessIcsText_AbweichungLoestSichSpaeterAuf_EntferntDieZuvorErzeugteVertretung() + { + var mappings = new FakeUntisSlotMappings(); + mappings.Add(new UntisSlotMapping + { + Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL", + ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true, + }); + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(mappings: mappings, substitutions: substitutions); + + // Poll 1: Fach weicht (noch) ab - Vertretung wird angelegt. + var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED")); + Assert.Equal(1, first.SubstitutionCount); + Assert.Single(substitutions.GetAll()); + + // Poll 2: derselbe Termin weicht jetzt nicht mehr ab (z.B. weil sich die WebUntis-Daten + // geändert haben oder ein Vergleichsfehler zwischenzeitlich behoben wurde) - die zuvor + // erzeugte Vertretung darf nicht als Karteileiche stehen bleiben. + var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED")); + + Assert.Equal(0, second.SubstitutionCount); + Assert.Empty(substitutions.GetAll()); + } + // ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht // im Plan [...] die Klasse ist dort weg" — end-to-end über ProcessIcsText, inkl. der neuen // Ferien-Ausschluss-Verdrahtung (ISchoolHolidayRepository -> UntisDiffService.freeDates). diff --git a/LehrerApp.Desktop/Services/UntisSyncService.cs b/LehrerApp.Desktop/Services/UntisSyncService.cs index e371011..3e9b009 100644 --- a/LehrerApp.Desktop/Services/UntisSyncService.cs +++ b/LehrerApp.Desktop/Services/UntisSyncService.cs @@ -124,6 +124,14 @@ public class UntisSyncService : IDisposable if (existing is not null) candidate.Id = existing.Id; _substitutions.Save(candidate); } + // Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe + // UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser + // ExternalId, ist das ein no-op. + foreach (var externalId in diffResult.SubstitutionExternalIdsToDelete) + { + var stale = _substitutions.GetByExternalId(externalId); + if (stale is not null) _substitutions.Delete(stale.Id); + } foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot); foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id); diff --git a/LehrerApp.Tests/UntisDiffServiceTests.cs b/LehrerApp.Tests/UntisDiffServiceTests.cs index 9174812..59297c0 100644 --- a/LehrerApp.Tests/UntisDiffServiceTests.cs +++ b/LehrerApp.Tests/UntisDiffServiceTests.cs @@ -54,6 +54,10 @@ public sealed class UntisDiffServiceTests Assert.Equal(SubstitutionKind.Lesson, entry.Kind); Assert.Equal("1", entry.ExternalId); Assert.Equal(1, entry.PeriodNumber); + // Diagnose direkt in der Beschreibung (siehe DeviationReason) - damit der Nutzer im + // Stundenplan selbst ablesen kann, welcher Vergleich genau fehlgeschlagen ist, ohne + // Rohdaten aus dem Feed teilen zu müssen. + Assert.Contains("Fach weicht ab", entry.Description); } [Fact] @@ -64,7 +68,8 @@ public sealed class UntisDiffServiceTests var result = new UntisDiffService().Diff(events, [], [mapping], Monday); - Assert.Single(result.SubstitutionsToSave); + var entry = Assert.Single(result.SubstitutionsToSave); + Assert.Contains("Klasse weicht ab", entry.Description); } // ── Regression: "Der Mathematik E-Kurs [...] ist ein Kurs aus den Klassen 10a, 10b und 10c @@ -105,6 +110,85 @@ public sealed class UntisDiffServiceTests Assert.Single(result.SubstitutionsToSave); } + // ── Regression, zweiter Anlauf: der erste Fix (Leerzeichen nur auf evt.Description-Seite + // entfernt) reichte nicht, wenn mapping.ClassToken SELBST ein eingebettetes Leerzeichen trägt + // — das passiert, wenn die reale DESCRIPTION Klassen nicht mit ";", sondern mit "," trennt: + // ExtractClassTokens fand dann kein ";" und speicherte "10a, 10b, 10c" als EIN Token samt + // Kommas/Leerzeichen statt drei getrennte Tokens. Ein Vergleich gegen die leerzeichenbereinigte + // Description konnte dieses Token dann strukturell NIE finden, unabhängig von der im Dialog + // gewählten Gruppe - genau das vom Nutzer beschriebene "immer noch nicht behoben". + + [Fact] + public void Diff_KombinierteGruppeMitKommaGetrenntemClassToken_ErzeugtKeineFalscheVertretung() + { + var mapping = new UntisSlotMapping + { + // So sieht ClassToken aus, wenn ExtractClassTokens die Klassen mangels ";" nicht + // aufsplitten konnte und "10a, 10b, 10c" als ein einziges Token übernommen hat. + Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "Mat_E", ClassToken = "10a, 10b, 10c", + GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true, + }; + var events = new List { BuildEvent("1", summary: "Mat_E", description: "10a, 10b, 10c HED") }; + + var result = new UntisDiffService().Diff(events, [], [mapping], Monday); + + Assert.Empty(result.SubstitutionsToSave); + } + + // ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus + // mehreren Klassen zusammengesetzt sind" — eine zuvor erkannte Abweichung, die beim nächsten + // Poll nicht mehr besteht, muss die ursprünglich erzeugte Vertretung wieder aufräumen, statt + // als Karteileiche stehen zu bleiben. + + [Fact] + public void Diff_UnveraendertesEreignis_MeldetDessenExternalIdZumAufraeumen() + { + var mapping = BuildMapping(Guid.NewGuid()); + var events = new List { BuildEvent("1") }; + + var result = new UntisDiffService().Diff(events, [], [mapping], Monday); + + Assert.Contains("1", result.SubstitutionExternalIdsToDelete); + } + + [Fact] + public void Diff_AufsichtWirdZurRegulaerenDuty_MeldetExternalIdZumAufraeumen() + { + var mapping = BuildSupervisionMapping(); + var events = new List + { + new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, + }; + var duties = new List { new() { Weekday = mapping.Weekday, AfterPeriod = mapping.AfterPeriod!.Value } }; + + var result = new UntisDiffService().Diff(events, [], [mapping], Monday, existingSupervisionDuties: duties); + + Assert.Empty(result.SubstitutionsToSave); + Assert.Contains("1", result.SubstitutionExternalIdsToDelete); + } + + [Fact] + public void Diff_VormalsFehlendeStundeIstWiederImFeed_MeldetMissingExternalIdZumAufraeumen() + { + var groupId = Guid.NewGuid(); + var mapping = new UntisSlotMapping + { + Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "8c", + GroupId = groupId, PeriodNumber = 3, Confirmed = true, + }; + var thisThursday = Monday.AddDays(3); + var currentEvents = new List + { + new() { Uid = "regular-1", Date = thisThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10), Summary = "NAT", Description = "8c HED" }, + }; + + var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday); + + Assert.Empty(result.SubstitutionsToSave); + var expectedMissingId = $"missing-{mapping.Id}-{thisThursday:yyyyMMdd}"; + Assert.Contains(expectedMissingId, result.SubstitutionExternalIdsToDelete); + } + [Fact] public void Diff_StatusCancelled_ErzeugtAusfall() { diff --git a/LehrerApp.Tests/UntisMatchingServiceTests.cs b/LehrerApp.Tests/UntisMatchingServiceTests.cs index 6ba408e..955a5d7 100644 --- a/LehrerApp.Tests/UntisMatchingServiceTests.cs +++ b/LehrerApp.Tests/UntisMatchingServiceTests.cs @@ -102,6 +102,29 @@ public sealed class UntisMatchingServiceTests Assert.True(match.IsConfident); } + [Fact] + public void BuildMatches_KombinierteKlassenKommaGetrennt_WerdenEbenfallsAufgesplittet() + { + // Nicht empirisch belegt, ob WebUntis mehrere Klassen mit ";" oder "," trennt (das einzige + // real verifizierte Beispiel hatte nur eine Klasse) - ExtractClassTokens darf sich deshalb + // nicht auf ";" verlassen, sonst landet "10a, 10b, 10c" als EIN Token mit Leerzeichen statt + // drei getrennten (siehe UntisDiffServiceTests: Diff_KombinierteGruppeMitKommaGetrenntemClassToken...). + var groupA = new LearningGroup { Name = "10a" }; + var groupB = new LearningGroup { Name = "10b" }; + var events = new List + { + Event(DayOfWeek.Wednesday, new TimeOnly(9, 40), new TimeOnly(11, 10), "Mat_E", "10a, 10b HED"), + }; + var existingSlot = new TimetableSlot { GroupId = groupB.Id, Weekday = DayOfWeek.Wednesday, PeriodNumber = 3 }; + + var result = new UntisMatchingService().BuildMatches(events, [groupA, groupB], [existingSlot], BuildPeriodSchedule()); + + var match = Assert.Single(result.Matches); + Assert.Equal(["10a", "10b"], match.Pattern.ClassTokens); + Assert.Equal(groupB.Id, match.SuggestedGroupId); + Assert.True(match.IsConfident); + } + [Fact] public void BuildMatches_KeinePassendeGruppe_BleibtUnbestaetigt() { diff --git a/TODO.md b/TODO.md index 834626f..099dd1f 100644 --- a/TODO.md +++ b/TODO.md @@ -1130,6 +1130,36 @@ wurde bei jedem Poll erneut als "abweichend" gemeldet. (`"10c"` in `"10c HED"`) waren nie betroffen, da dort kein Trennzeichen im Spiel ist; deshalb ist der Fehler dem Nutzer erst bei diesem speziellen Kurs aufgefallen. +**Nachtrag zu 4.3, sechzehnte Iteration (Bugfix: falsche Vertretungen für kombinierte Gruppen +blieben trotz behobenem Vergleich stehen):** Nutzer-Feedback nach der fünfzehnten Iteration: "Es +klappt nicht. [...] Die falschen Vertretungen stehen noch bei den Kursen. Immer die Kurse mit +Lerngruppen, die aus mehreren Klassen zusammengesetzt sind." Zwei Ursachen, nacheinander gefunden: +- Der Fix aus der fünfzehnten Iteration entfernte Leerzeichen nur auf der `evt.Description`-Seite, + nicht auf `mapping.ClassToken` selbst — schlug also weiterhin fehl, sobald `ClassToken` ein + eingebettetes Leerzeichen trägt (z.B. wenn `ExtractClassTokens` die Klassen mangels bekanntem + Trennzeichen nicht aufsplitten konnte). Fix: `RemoveWhitespace` symmetrisch auf beide Seiten + angewendet; `ExtractClassTokens` akzeptiert jetzt zusätzlich `,` als Trennzeichen (nicht nur + `;`), da nie an einem echten kombinierten Termin verifiziert wurde, welches WebUntis tatsächlich + verwendet. Zur Eingrenzung ohne weitere Rateversuche schreibt eine abweichende Vertretung jetzt + außerdem den genauen fehlgeschlagenen Vergleich (roher Fach-/Klassen-Wert) in ihre eigene + Beschreibung (`UntisDiffService.DeviationReason`). +- Der eigentliche, tiefere Bug: der Nutzer bestätigte danach neu und der Vergleich lief + nachweislich mit aktuellem Code (die neue Diagnose-Beschreibung erschien auf einer anderen, + echten Abweichung), aber die falschen Vertretungen für Mathe-E-Kurs/Chemie G blieben ohne die + neue Diagnose-Beschreibung stehen — sie waren Karteileichen aus einem Poll VOR dem Fix. + `UntisDiffService.Diff` schrieb bislang ausschließlich neu erkannte/weiterhin bestehende + Abweichungen; es gab keinen Pfad, der eine zuvor automatisch erzeugte `SubstitutionEntry` + wieder entfernt, sobald ein späterer Poll dieselbe Zeile nicht mehr als Abweichung einstuft. + Fix: `UntisDiffResult` bekommt `SubstitutionExternalIdsToDelete` — befüllt für (a) eine + Unterrichtsstunde, die nicht mehr abweicht, (b) eine Aufsicht, die jetzt einer regulären + `SupervisionDuty` entspricht, und (c) eine zuvor als fehlend gemeldete Stunde, die im Feed + wieder auftaucht. `UntisSyncService.ProcessIcsText` löscht dafür den vorhandenen Eintrag über + `ISubstitutionEntryRepository.GetByExternalId`/`Delete` (no-op, falls keiner existiert). +- Damit räumen sich einmal fälschlich erzeugte automatische Vertretungen künftig von selbst auf, + sobald der zugrundeliegende Vergleich beim nächsten Poll keine Abweichung mehr findet — nicht + nur bei diesem konkreten Bugfix, sondern auch bei jeder künftigen Korrektur der Zuordnung durch + den Nutzer selbst. + ### 4.4 Wochen-/Tagesansicht - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.