fix: Namensabgleich und Tab-Wechsel im Klassenlehrer-Bereich, Fehltag-Hervorhebung
Zwei Bugs behoben: - Gefilterte Listen blieben nach Klick auf eine Roster-Zeile leer: WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge (Schülerreport "Vorname Nachname", Fehlzeiten-/Klassenbuchberichte "Nachname Vorname"). Neuer reihenfolge-unabhängiger Namensvergleich (UntisNameMatching), betraf auch den Roster-Namensabgleich der Übersicht selbst (Ampel-Symbole/Badges waren ohne ExternKey vermutlich fast immer falsch). - Nach dem ersten Tab-Wechsel (Klick auf Roster-Zeile) blieb ein zweiter Klick wirkungslos, sobald man zwischendurch manuell zur Übersicht zurückgeklickt hatte: TabbedPage.SelectedIndex war ohne Mode=TwoWay gebunden. Explizit ergänzt. Zusätzlich: Fehlzeiten-Zeilen für einen kompletten Fehltag werden jetzt dezent hervorgehoben (Nutzer-Feedback, bisher nicht erkennbar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -100,8 +100,33 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.False(cem.HasRecentClassRegisterEntry);
|
Assert.False(cem.HasRecentClassRegisterEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UntisStudentDto Student(int externKey, string displayName) => new(
|
[Fact]
|
||||||
UntisId: externKey, ExternKey: externKey, ClassName: "6a", Name: displayName, LongName: null,
|
public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge()
|
||||||
|
{
|
||||||
|
// WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/
|
||||||
|
// Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich
|
||||||
|
// muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug).
|
||||||
|
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
|
||||||
|
var todayAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45,
|
||||||
|
["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
var recentClassRegister = new[]
|
||||||
|
{
|
||||||
|
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller",
|
||||||
|
"Vergessen", "Organisation", "Buch vergessen"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
|
||||||
|
|
||||||
|
var ben = Assert.Single(roster);
|
||||||
|
Assert.True(ben.HasAbsenceToday);
|
||||||
|
Assert.True(ben.HasRecentClassRegisterEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UntisStudentDto Student(int? externKey, string displayName) => new(
|
||||||
|
UntisId: externKey ?? 0, ExternKey: externKey, ClassName: "6a", Name: displayName, LongName: null,
|
||||||
ForeName: null, DisplayName: displayName, Gender: null, BirthDate: null, BirthDateRaw: null,
|
ForeName: null, DisplayName: displayName, Gender: null, BirthDate: null, BirthDateRaw: null,
|
||||||
EntryDate: null, EntryDateRaw: null, ExitDate: null, ExitDateRaw: null, Text: null,
|
EntryDate: null, EntryDateRaw: null, ExitDate: null, ExitDateRaw: null, Text: null,
|
||||||
MedicalReportDuty: null, Schulpflicht: null, Majority: null,
|
MedicalReportDuty: null, Schulpflicht: null, Majority: null,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class UntisNameMatchingTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Ben Schmidt", "Schmidt Ben")]
|
||||||
|
[InlineData("Ada Müller", "MÜLLER ADA")]
|
||||||
|
[InlineData("Max Mustermann", "mustermann max")]
|
||||||
|
public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) =>
|
||||||
|
Assert.True(UntisNameMatching.NamesMatch(a, b));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Ben Schmidt", "Ben Müller")]
|
||||||
|
[InlineData(null, "Ben Schmidt")]
|
||||||
|
[InlineData("Ben Schmidt", "")]
|
||||||
|
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
||||||
|
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
|
|||||||
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
|
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
|
||||||
public string StatusLabel => string.Join(", ", Statuses);
|
public string StatusLabel => string.Join(", ", Statuses);
|
||||||
public string ReasonLabel => string.Join("; ", AbsenceReasons);
|
public string ReasonLabel => string.Join("; ", AbsenceReasons);
|
||||||
|
/// Nutzer-Feedback: ein ganzer Fehltag ist in der Liste sonst nicht auf einen Blick erkennbar -
|
||||||
|
/// dezente Hervorhebung statt einer weiteren Text-/Icon-Spalte.
|
||||||
|
public string RowBackground => CountsAsFullDay ? "#1FFFA000" : "Transparent";
|
||||||
|
public string? FullDayTooltip => CountsAsFullDay ? "Ganzer Fehltag" : null;
|
||||||
|
|
||||||
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
|
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
|
||||||
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
|
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
|
||||||
@@ -138,13 +142,14 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
|
|||||||
finally { Busy = false; }
|
finally { Busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
|
||||||
|
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
|
||||||
|
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
|
||||||
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
|
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
|
||||||
string.IsNullOrWhiteSpace(StudentFilter) ||
|
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||||||
string.Equals(entry.StudentName, StudentFilter, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
|
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
|
||||||
string.IsNullOrWhiteSpace(StudentFilter) ||
|
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
|
||||||
string.Equals(entry.StudentName, StudentFilter, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static bool TryDate(int value, out DateOnly date) =>
|
private static bool TryDate(int value, out DateOnly date) =>
|
||||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
|
|
||||||
/// <summary>Führt die WebUntis-Schülerliste der Klasse mit den heutigen Fehlzeiten und den
|
/// <summary>Führt die WebUntis-Schülerliste der Klasse mit den heutigen Fehlzeiten und den
|
||||||
/// jüngsten Klassenbucheinträgen zusammen. Zuordnung primär über <c>ExternKey</c> (beide
|
/// jüngsten Klassenbucheinträgen zusammen. Zuordnung primär über <c>ExternKey</c> (beide
|
||||||
/// stammen aus WebUntis, gleiche Kennung), Fallback auf Namensgleichheit für den
|
/// stammen aus WebUntis, gleiche Kennung), Fallback auf reihenfolge-unabhängigen Namensabgleich
|
||||||
/// Klassenbuch-Bericht, der keinen ExternKey liefert (siehe UntisForeignClassRegisterEventDto).</summary>
|
/// (<see cref="UntisNameMatching"/>) für den Klassenbuch-Bericht, der keinen ExternKey liefert
|
||||||
|
/// (siehe UntisForeignClassRegisterEventDto) — WebUntis liefert Namen dort in anderer
|
||||||
|
/// Reihenfolge als im Schülerreport.</summary>
|
||||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||||
IReadOnlyList<UntisStudentDto> students,
|
IReadOnlyList<UntisStudentDto> students,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||||
@@ -29,20 +31,21 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
.GroupBy(a => a.ExternKey!.Value)
|
.GroupBy(a => a.ExternKey!.Value)
|
||||||
.ToDictionary(g => g.Key, g => g.First());
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
var absenceByName = todayAbsences
|
var absenceByName = todayAbsences
|
||||||
.GroupBy(a => a.StudentName, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
var recentNames = recentClassRegisterEntries
|
var recentNames = recentClassRegisterEntries
|
||||||
.Select(e => e.StudentName)
|
.Select(e => UntisNameMatching.NameKey(e.StudentName))
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet();
|
||||||
|
|
||||||
return students
|
return students
|
||||||
.Select(s =>
|
.Select(s =>
|
||||||
{
|
{
|
||||||
|
var nameKey = UntisNameMatching.NameKey(s.DisplayName);
|
||||||
var absence = (s.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
var absence = (s.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
||||||
?? absenceByName.GetValueOrDefault(s.DisplayName);
|
?? absenceByName.GetValueOrDefault(nameKey);
|
||||||
return new ClassTeacherRosterRow(s.DisplayName, s.ExternKey, absence is not null,
|
return new ClassTeacherRosterRow(s.DisplayName, s.ExternKey, absence is not null,
|
||||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||||
recentNames.Contains(s.DisplayName));
|
recentNames.Contains(nameKey));
|
||||||
})
|
})
|
||||||
.OrderBy(r => r.StudentName)
|
.OrderBy(r => r.StudentName)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge — der
|
||||||
|
/// Schülerreport baut "Vorname Nachname" (<see cref="Services.UntisStudentDto.DisplayName"/>),
|
||||||
|
/// die Fehlzeiten-/Klassenbuchberichte liefern typischerweise "Nachname Vorname". Ein exakter
|
||||||
|
/// String-Vergleich zwischen diesen Quellen schlägt deshalb praktisch immer fehl (Bug: gefilterte
|
||||||
|
/// Listen im Klassenlehrer-Bereich blieben leer, Roster-Symbole waren durchgängig falsch). Der
|
||||||
|
/// Vergleich hier ist deshalb reihenfolge-unabhängig: beide Namen werden in Wörter zerlegt und als
|
||||||
|
/// sortierte Menge verglichen (kein Abgleich über eine externe Schülerkennung möglich, da der
|
||||||
|
/// Klassenbuch-Bericht keine liefert, siehe UntisForeignClassRegisterEventDto).
|
||||||
|
/// </summary>
|
||||||
|
public static class UntisNameMatching
|
||||||
|
{
|
||||||
|
public static bool NamesMatch(string? a, string? b) =>
|
||||||
|
!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())
|
||||||
|
.OrderBy(w => w, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
@@ -69,20 +69,23 @@
|
|||||||
<ItemsControl ItemsSource="{Binding AbsenceEntries}">
|
<ItemsControl ItemsSource="{Binding AbsenceEntries}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
|
<DataTemplate x:DataType="vm:ClassAbsenceDaySummaryRow">
|
||||||
<Grid ColumnDefinitions="70,1*,1.3*,0.5*,0.6*,0.6*,0.8*,1.5*" ColumnSpacing="8" Margin="0,3">
|
<Border Background="{Binding RowBackground}" CornerRadius="4" Padding="0,2"
|
||||||
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
ToolTip.Tip="{Binding FullDayTooltip}">
|
||||||
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
<Grid ColumnDefinitions="70,1*,1.3*,0.5*,0.6*,0.6*,0.8*,1.5*" ColumnSpacing="8" Margin="4,1">
|
||||||
<TextBlock Grid.Column="2" Text="{Binding SubjectsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
<TextBlock Grid.Column="0" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="3" Text="{Binding PeriodsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
<TextBlock Grid.Column="1" Text="{Binding StudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
<TextBlock Grid.Column="4" Text="{Binding TotalAbsentPeriods}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="2" Text="{Binding SubjectsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
<TextBlock Grid.Column="5" Text="{Binding TotalAbsentMinutes}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="3" Text="{Binding PeriodsLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
<TextBlock Grid.Column="6" Text="{Binding StatusLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
<TextBlock Grid.Column="4" Text="{Binding TotalAbsentPeriods}" VerticalAlignment="Center"/>
|
||||||
<StackPanel Grid.Column="7" Spacing="0">
|
<TextBlock Grid.Column="5" Text="{Binding TotalAbsentMinutes}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Text="{Binding ReasonLabel}" TextWrapping="Wrap"/>
|
<TextBlock Grid.Column="6" Text="{Binding StatusLabel}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
<TextBlock Text="{Binding Note}" FontSize="11" Opacity="0.6" TextWrapping="Wrap"
|
<StackPanel Grid.Column="7" Spacing="0">
|
||||||
IsVisible="{Binding Note, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
<TextBlock Text="{Binding ReasonLabel}" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
<TextBlock Text="{Binding Note}" FontSize="11" Opacity="0.6" TextWrapping="Wrap"
|
||||||
</Grid>
|
IsVisible="{Binding Note, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TabbedPage IsVisible="{Binding HomeroomClassConfigured}" TabPlacement="Top"
|
<TabbedPage IsVisible="{Binding HomeroomClassConfigured}" TabPlacement="Top"
|
||||||
SelectedIndex="{Binding ActiveTabIndex}">
|
SelectedIndex="{Binding ActiveTabIndex, Mode=TwoWay}">
|
||||||
|
|
||||||
<ContentPage Header="Übersicht">
|
<ContentPage Header="Übersicht">
|
||||||
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="8">
|
<Grid RowDefinitions="Auto,Auto,*" Margin="16" RowSpacing="8">
|
||||||
|
|||||||
@@ -1362,6 +1362,29 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
|||||||
(`forceRefresh: true`) für den Fall, dass man sicher weiß, dass sich etwas geändert hat.
|
(`forceRefresh: true`) für den Fall, dass man sicher weiß, dass sich etwas geändert hat.
|
||||||
**Zurückgestellt:** Muster-Erkennung/Heuristiken über die gecachten Daten (vom Nutzer als Motiv
|
**Zurückgestellt:** Muster-Erkennung/Heuristiken über die gecachten Daten (vom Nutzer als Motiv
|
||||||
für den Cache genannt) — erst sinnvoll, wenn genug Historie im Cache liegt.
|
für den Cache genannt) — erst sinnvoll, wenn genug Historie im Cache liegt.
|
||||||
|
- [x] **Zwei echte Fehler gefunden und behoben (August 2026):**
|
||||||
|
- Gefilterte Listen im Klassenlehrer-Bereich blieben nach Klick auf eine Roster-Zeile leer,
|
||||||
|
obwohl Einträge vorhanden sein sollten. Ursache: WebUntis liefert Schülernamen je nach Bericht
|
||||||
|
in unterschiedlicher Reihenfolge — der Schülerreport baut "Vorname Nachname"
|
||||||
|
(`UntisStudentDto.DisplayName`), Fehlzeiten-/Klassenbuchberichte liefern typischerweise
|
||||||
|
"Nachname Vorname". Der exakte String-Vergleich beim Filtern (`ClassTeacherDetailsViewModel`)
|
||||||
|
schlug deshalb praktisch immer fehl — derselbe Fehler steckte unbemerkt auch schon im
|
||||||
|
Roster-Namensabgleich der Übersicht (`ClassTeacherRosterRow.Build`, Fallback wenn kein
|
||||||
|
`ExternKey` vorliegt), dort blieben Ampel-Symbol und Klassenbuch-Badge also vermutlich fast
|
||||||
|
immer falsch. Neuer reihenfolge-unabhängiger Namensvergleich
|
||||||
|
(`ClassTeacher/UntisNameMatching.NamesMatch`: Namen in Wörter zerlegt, als sortierte Menge
|
||||||
|
verglichen), an beiden Stellen eingesetzt.
|
||||||
|
- Nach dem ersten Klick auf eine Roster-Zeile (Wechsel in die Details) blieb ein zweiter Klick
|
||||||
|
wirkungslos, sobald man zwischendurch manuell zur Übersicht zurückgeklickt hatte — man blieb
|
||||||
|
auf der Übersicht hängen. Ursache: `TabbedPage.SelectedIndex="{Binding ActiveTabIndex}"` ohne
|
||||||
|
`Mode=TwoWay` ist effektiv nur VM→View gebunden; ein manueller Tab-Klick des Nutzers schreibt
|
||||||
|
`ActiveTabIndex` im ViewModel nicht zurück auf 0, wodurch ein erneutes `ActiveTabIndex = 1` aus
|
||||||
|
`ShowDetailsForStudent` denselben Wert setzt und mangels Änderung kein `PropertyChanged`
|
||||||
|
auslöst. Fix: `Mode=TwoWay` explizit ergänzt (`ClassTeacherOverviewView.axaml`). Dasselbe
|
||||||
|
Bindungsmuster ohne `Mode=TwoWay` existiert auch in `GroupDetailView.axaml`/`WorkloadView.axaml`
|
||||||
|
(dortige `OnActiveTabIndexChanged`-Refreshs beim Zurückkehren zu einem Tab greifen bei einem
|
||||||
|
reinen Nutzerklick möglicherweise ebenfalls nicht) — als Hintergrundaufgabe zur Prüfung
|
||||||
|
ausgelagert, nicht Teil dieser Änderung.
|
||||||
|
|
||||||
### 4.4 Wochen-/Tagesansicht
|
### 4.4 Wochen-/Tagesansicht
|
||||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||||
|
|||||||
Reference in New Issue
Block a user