From b3b86f0cf4594582c23c38309a7d397b7fcaf01e Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 27 Aug 2026 13:25:32 +0200 Subject: [PATCH] =?UTF-8?q?Klassenlehrer-Tab:=20restliche=20Quick-Wins=20(?= =?UTF-8?q?responsive=20Balken,=20Tages=C3=BCberblick-Chart,=20Tooltips,?= =?UTF-8?q?=20Status-Symbol)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balkenbreiten hingen bisher an einer im ViewModel hart codierten Pixelkonstante; Grid.ColumnDefinitions ließ sich dafür nicht binden (AVLN3000, kein Setter bei kompilierten Bindings), deshalb neuer FractionWidthConverter (MultiBinding gegen die gerenderte Breite einer Track-Border statt Grid-Sternspalten). Tagesüberblick zeigt jetzt denselben gestapelten Balken statt vier Einzelzeilen. AbsenceTooltip und TodayUnexcusedPercent waren berechnet aber ungebunden, jetzt sichtbar. Status-Zeilen bekommen zusätzlich zur Farbe ein vorangestelltes Symbol (Barrierefreiheit). Co-Authored-By: Claude Sonnet 5 --- .../ClassTeacherViewModelsTests.cs | 39 +++++- .../FractionWidthConverterTests.cs | 37 ++++++ .../Converters/FractionWidthConverter.cs | 24 ++++ .../ClassTeacherOverviewViewModel.cs | 60 ++++++++- .../ClassTeacherOverviewView.axaml | 117 +++++++++++++++--- TODO.md | 43 ++++++- 6 files changed, 291 insertions(+), 29 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/FractionWidthConverterTests.cs create mode 100644 LehrerApp.Desktop/Converters/FractionWidthConverter.cs diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 327a50b..37433d7 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -224,11 +224,44 @@ public sealed class ClassTeacherViewModelsTests } [Fact] - public void TrendDay_BalkenbreitenSummierenSichZurGesamtbreite() + public void TrendDay_AnteileSummierenSichZurGesamtquote() { - var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 40d, 20d, 40d); + var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d); - Assert.Equal(100d, day.TotalBarWidth); + Assert.Equal(1.0d, day.TotalFraction, precision: 10); + } + + [Fact] + public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran() + { + var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") }; + var todayAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90, + ["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90, + ["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false), + }; + + var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26)); + + var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller"); + Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind); + Assert.StartsWith("✕ ", ada.StatusTextWithGlyph); + + var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt"); + Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind); + Assert.StartsWith("△ ", ben.StatusTextWithGlyph); + } + + [Fact] + public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol() + { + var students = new[] { Student(1001, "Zora Unauffällig") }; + + var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26))); + + Assert.Equal(row.StatusText, row.StatusTextWithGlyph); } private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => diff --git a/LehrerApp.Desktop.Tests/FractionWidthConverterTests.cs b/LehrerApp.Desktop.Tests/FractionWidthConverterTests.cs new file mode 100644 index 0000000..07f201f --- /dev/null +++ b/LehrerApp.Desktop.Tests/FractionWidthConverterTests.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using LehrerApp.Desktop.Converters; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class FractionWidthConverterTests +{ + [Fact] + public void Convert_MultipliziertTrackbreiteMitAnteil() + { + var result = FractionWidthConverter.Instance.Convert( + [200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture); + + Assert.Equal(50d, result); + } + + [Fact] + public void Convert_LiefertNullBreiteBeiUngueltigenWerten() + { + // Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) — + // darf nicht zu einer negativen oder undefinierten Breite führen. + Assert.Equal(0d, FractionWidthConverter.Instance.Convert( + [double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture)); + Assert.Equal(0d, FractionWidthConverter.Instance.Convert( + ["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture)); + } + + [Fact] + public void Convert_ClamptNegativeErgebnisseAufNull() + { + var result = FractionWidthConverter.Instance.Convert( + [100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture); + + Assert.Equal(0d, result); + } +} diff --git a/LehrerApp.Desktop/Converters/FractionWidthConverter.cs b/LehrerApp.Desktop/Converters/FractionWidthConverter.cs new file mode 100644 index 0000000..2380748 --- /dev/null +++ b/LehrerApp.Desktop/Converters/FractionWidthConverter.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace LehrerApp.Desktop.Converters; + +/// Multipliziert die gerenderte Breite eines Referenzelements (typischerweise die neutrale +/// Track-Leiste eines gestapelten Balkens) mit einem Anteil 0…1. Ersetzt eine im ViewModel hart +/// codierte Pixelkonstante (Quick-Win, Nutzer-Feedback) — die Segmentbreite bleibt damit auch +/// korrekt, wenn sich die Breite der Seitenspalte in XAML mal ändert, weil sie sich aus der +/// tatsächlichen Bounds.Width des Track-Elements ergibt statt aus einer angenommenen +/// festen Breite. `Grid.ColumnDefinitions` ließ sich dafür nicht binden (Avalonia bietet dort bei +/// kompilierten Bindings keinen Setter — AVLN3000), deshalb MultiBinding auf Pixelbreite +/// statt Grid-Sternspalten. +public sealed class FractionWidthConverter : IMultiValueConverter +{ + public static readonly FractionWidthConverter Instance = new(); + + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values is not [double trackWidth, double fraction, ..] || double.IsNaN(trackWidth)) return 0d; + return Math.Max(0d, trackWidth * fraction); + } +} diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 71b2e7d..0e534dd 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -59,6 +59,16 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" : HasRecentClassRegisterEntry ? "Eintrag diese Woche" : ""; + /// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des + /// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr + /// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste). + public string StatusTextWithGlyph => StatusKind switch + { + ClassTeacherStatusKind.Danger => $"✕ {StatusText}", + ClassTeacherStatusKind.Warning => $"△ {StatusText}", + _ => StatusText, + }; + /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein /// sagt für Zeugnis/Attestpflicht wenig aus). zählt nur /// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag. @@ -128,13 +138,22 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b /// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl /// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei /// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang -/// vor Verspätet, wie bei ) und ihre Breiten -/// summieren sich exakt zu . +/// vor Verspätet, wie bei ). public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, - int LateExcusedCount, int ExcusedCount, double UnexcusedBarWidth, double LateExcusedBarWidth, - double ExcusedBarWidth) + int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction, + double ExcusedFraction) { - public double TotalBarWidth => UnexcusedBarWidth + LateExcusedBarWidth + ExcusedBarWidth; + /// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten + /// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart + /// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML + /// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding + /// () mit der tatsächlich gerenderten Breite + /// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere + /// Breite bekommt. Grid.ColumnDefinitions ließ sich dafür nicht binden (kein Setter bei + /// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten. + public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction; + public string DetailTooltip => + $"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt"; } public sealed record ClassTeacherPatternNotice(string StudentName, string Message, ClassTeacherStatusKind Kind) @@ -189,6 +208,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount); public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount); + /// Quick-Win: wurde berechnet, aber nirgends gebunden — + /// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar. + public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %"; + + /// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken + /// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer + /// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe + /// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in . + public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %"; + public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %"; + public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %"; + public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %"; + /// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding + /// mit der gerenderten Track-Breite, siehe + /// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich + /// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence. + public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount; + public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount; + public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount; + public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount; + public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " + + $"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt"; + public Func? OnNavigateToSettings { get; set; } public Func? OnNavigateToWorkload { get; set; } @@ -348,7 +390,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject foreach (var day in counts) TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"), day.Total, day.Unexcused, day.LateExcused, day.Excused, - 112d * day.Unexcused / max, 112d * day.LateExcused / max, 112d * day.Excused / max)); + (double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max)); } /// Letzte Werktage bis einschließlich , ohne @@ -400,6 +442,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent)); OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent)); OnPropertyChanged(nameof(UnexcusedAbsencePercent)); + OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel)); + OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel)); + OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel)); + OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction)); + OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction)); + OnPropertyChanged(nameof(DayOverviewTooltip)); } private void NotifyRosterState() { diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index 99675ec..2a8ad6b 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher" xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher" + xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView" x:DataType="vm:ClassTeacherOverviewViewModel"> @@ -98,7 +99,8 @@ Classes.danger="{Binding IsDangerStatus}"/> + HorizontalAlignment="Center" VerticalAlignment="Center" + ToolTip.Tip="{Binding AbsenceTooltip}"> @@ -106,7 +108,9 @@ TextTrimming="CharacterEllipsis"/> - + - + @@ -227,10 +231,58 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -242,17 +294,50 @@ - - + + - + (siehe ClassTeacherTrendDay). Jedes Segment multipliziert per + MultiBinding die gerenderte Breite von "TrendTrack" mit seinem + Anteil 0…1 (FractionWidthConverter) — bleibt damit auch dann + korrekt proportional, wenn die Seitenspalte in XAML mal eine + andere Breite bekommt. Der äußere Wrapper ist auf CornerRadius + zugeschnitten, damit nur die äußeren Kanten rund sind, nicht + jedes Segment einzeln. --> + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TODO.md b/TODO.md index c1fd8ea..c4e8c1d 100644 --- a/TODO.md +++ b/TODO.md @@ -1403,10 +1403,11 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt - hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`), - deren Breiten sich exakt zu `TotalBarWidth` summieren; in XAML als `Border ClipToBounds="True"` - mit `CornerRadius` um eine horizontale `StackPanel` aus drei quadratischen Segmenten gerendert, - damit nur die äußeren Kanten abgerundet sind. + hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`); + in XAML als `Border ClipToBounds="True"` mit `CornerRadius` um eine horizontale `StackPanel` + aus drei Segmenten gerendert, damit nur die äußeren Kanten abgerundet sind. Wie die + Segmentbreiten selbst berechnet werden, hat sich im Nachtrag unten noch einmal geändert — + siehe dort. - **Trend über die letzten 7 Schultage statt Kalendertage:** an zwei von sieben Tagen war der Balken bislang systematisch leer, weil `start.AddDays(offset)` Wochenenden mitzählte. Neues `ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne @@ -1423,6 +1424,40 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen). Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection, Service war bereits als Singleton registriert). +- [x] **"Klassenlehrer"-Feature — restliche Quick-Wins (August 2026):** Die vier verbliebenen + Punkte aus derselben Vorschlagsliste wie oben. + - **Balkenbreiten responsiv statt fester Pixelkonstante:** `BuildTrend` rechnete bisher mit + einer im ViewModel hart codierten Pixelbreite (`112d`), die mit der tatsächlichen Breite der + Seitenspalte in XAML synchron gehalten werden musste. Erster Versuch — `Grid.ColumnDefinitions` + per `{Binding}` auf einen "N*,N*,…"-String setzen, wie es die eingebaute XAML-Stringsyntax + erlaubt — scheiterte an `AVLN3000` ("Unable to find suitable setter"): `ColumnDefinitions` hat + bei kompilierten Bindings (`x:DataType`) keinen normalen Property-Setter, auch nicht über einen + `IValueConverter`. Stattdessen liefert `ClassTeacherTrendDay`/`ClassTeacherOverviewViewModel` + jetzt Anteile 0…1 (`UnexcusedFraction`/`LateExcusedFraction`/`ExcusedFraction` bzw. + `Present-/Late-/ExcusedAbsence-/UnexcusedAbsenceFraction`), und die View multipliziert sie per + `MultiBinding` mit der tatsächlich gerenderten Breite einer benannten, unsichtbaren + Track-`Border` (`ElementName=…, Path=Bounds.Width`) — neuer + [FractionWidthConverter.cs](LehrerApp.Desktop/Converters/FractionWidthConverter.cs) + (`IMultiValueConverter`). Bleibt dadurch korrekt, unabhängig davon, wie breit die Seitenspalte + gerade ist, ohne dass View und ViewModel einen Pixelwert teilen müssten. + - **Tagesüberblick als gestapelter 100-%-Balken statt vier Einzelzeilen:** neue `Grid` mit + demselben Track-`Border`/`MultiBinding`/`FractionWidthConverter`-Muster wie beim Trend, direkt + über der (weiterhin vorhandenen, jetzt kompakteren) Legende. Die vier Kategorien sind exklusiv + und summieren sich exakt zu `StudentCount`, brauchen also anders als beim Trend keine + Rest-Kategorie für den Sonderfall "unter dem Maximum". + - **`AbsenceTooltip` und `TodayUnexcusedPercent` gebunden:** beide wurden berechnet, aber nie + angezeigt. `AbsenceTooltip` hängt jetzt als `ToolTip.Tip` am Avatar-Kreis jeder Roster-Zeile; + `TodayUnexcusedPercent` steckt in einem neuen `TodayUnexcusedSummaryLabel` unter der + "unentschuldigt"-Kennzahlkarte oben. Die vier vormals ungenutzten Prozent-Properties + (`Present-/Late-/ExcusedAbsence-/UnexcusedAbsencePercent`) sind jetzt ebenfalls in + Summary-Labels der neuen Tagesüberblick-Legende eingebettet, statt einer eigenen + rechtsbündigen Spalte wie zuvor. + - **Symbol zusätzlich zur Farbe (Barrierefreiheit):** der Status einer Roster-Zeile hing zuvor + an der Farbe der linken 4-px-Leiste plus reinem Text ohne visuellen Vorrang. Neues + `ClassTeacherRosterRow.StatusTextWithGlyph` stellt "✕ " (Gefahr) bzw. "△ " (Warnung) voran — + dieselben Symbole wie in den Kennzahlkarten oben, damit sich die Bedeutung nicht neu erlernen + lässt. `StatusText` selbst bleibt unverändert (bestehende Tests/Bindings), das Symbol ist eine + zusätzliche, separat getestete Property. ### 4.4 Wochen-/Tagesansicht