Klassenlehrer-Tab: restliche Quick-Wins (responsive Balken, Tagesüberblick-Chart, Tooltips, Status-Symbol)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:25:32 +02:00
co-authored by Claude Sonnet 5
parent 9d375caf14
commit b3b86f0cf4
6 changed files with 291 additions and 29 deletions
@@ -224,11 +224,44 @@ public sealed class ClassTeacherViewModelsTests
} }
[Fact] [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) => private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
@@ -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);
}
}
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
namespace LehrerApp.Desktop.Converters;
/// <summary>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 <c>Bounds.Width</c> 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 — <c>AVLN3000</c>), deshalb MultiBinding auf Pixelbreite
/// statt Grid-Sternspalten.</summary>
public sealed class FractionWidthConverter : IMultiValueConverter
{
public static readonly FractionWidthConverter Instance = new();
public object? Convert(IList<object?> 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);
}
}
@@ -59,6 +59,16 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" : public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : ""; 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 /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur /// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag. /// 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 /// 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 /// 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 /// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>) und ihre Breiten /// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>).
/// summieren sich exakt zu <see cref="TotalBarWidth"/>.
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
int LateExcusedCount, int ExcusedCount, double UnexcusedBarWidth, double LateExcusedBarWidth, int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction,
double ExcusedBarWidth) 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
/// (<see cref="LehrerApp.Desktop.Converters.FractionWidthConverter"/>) mit der tatsächlich gerenderten Breite
/// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere
/// Breite bekommt. <c>Grid.ColumnDefinitions</c> 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, public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
ClassTeacherStatusKind Kind) ClassTeacherStatusKind Kind)
@@ -189,6 +208,29 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount); public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount); public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
/// Quick-Win: <see cref="TodayUnexcusedPercent"/> 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 <see cref="Load"/>.
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 <see cref="ClassTeacherTrendDay.TotalFraction"/>
/// 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<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
@@ -348,7 +390,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
foreach (var day in counts) foreach (var day in counts)
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"), TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
day.Total, day.Unexcused, day.LateExcused, day.Excused, 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 <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne /// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
@@ -400,6 +442,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent)); OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent)); OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
OnPropertyChanged(nameof(UnexcusedAbsencePercent)); 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() private void NotifyRosterState()
{ {
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher" xmlns:views="clr-namespace:LehrerApp.Desktop.Views.ClassTeacher"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView" x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
x:DataType="vm:ClassTeacherOverviewViewModel"> x:DataType="vm:ClassTeacherOverviewViewModel">
@@ -98,7 +99,8 @@
Classes.danger="{Binding IsDangerStatus}"/> Classes.danger="{Binding IsDangerStatus}"/>
<Border Grid.Column="1" Width="28" Height="28" CornerRadius="14" <Border Grid.Column="1" Width="28" Height="28" CornerRadius="14"
BorderBrush="{DynamicResource AppAvatarBorderBrush}" BorderThickness="1" BorderBrush="{DynamicResource AppAvatarBorderBrush}" BorderThickness="1"
HorizontalAlignment="Center" VerticalAlignment="Center"> HorizontalAlignment="Center" VerticalAlignment="Center"
ToolTip.Tip="{Binding AbsenceTooltip}">
<TextBlock Text="{Binding Initials}" FontSize="10" <TextBlock Text="{Binding Initials}" FontSize="10"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border> </Border>
@@ -106,7 +108,9 @@
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<StackPanel Grid.Column="3" VerticalAlignment="Center" Spacing="1" <StackPanel Grid.Column="3" VerticalAlignment="Center" Spacing="1"
ToolTip.Tip="{Binding YearSummaryTooltip}"> ToolTip.Tip="{Binding YearSummaryTooltip}">
<TextBlock Text="{Binding StatusText}" Classes="status" <!-- Quick-Win Barrierefreiheit: Status nicht ausschließlich über Farbe, sondern auch
über ein vorangestelltes Symbol bei Warnung/Gefahr (StatusTextWithGlyph). -->
<TextBlock Text="{Binding StatusTextWithGlyph}" Classes="status"
Classes.info="{Binding IsInfoStatus}" Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}" Classes.warning="{Binding IsWarningStatus}"
Classes.danger="{Binding IsDangerStatus}" Classes.danger="{Binding IsDangerStatus}"
@@ -178,7 +182,7 @@
<Grid ColumnDefinitions="36,*"> <Grid ColumnDefinitions="36,*">
<TextBlock Text="!" FontSize="22" VerticalAlignment="Center" <TextBlock Text="!" FontSize="22" VerticalAlignment="Center"
Foreground="{DynamicResource AppStatusDangerBrush}"/> Foreground="{DynamicResource AppStatusDangerBrush}"/>
<StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayUnexcusedCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="unentschuldigt" FontSize="11" Opacity="0.6"/></StackPanel> <StackPanel Grid.Column="1"><TextBlock Text="{Binding TodayUnexcusedCount}" FontSize="22" FontWeight="SemiBold"/><TextBlock Text="{Binding TodayUnexcusedSummaryLabel}" FontSize="11" Opacity="0.6"/></StackPanel>
</Grid> </Grid>
</Border> </Border>
<Border Grid.Column="3" Classes="metric" Margin="0"> <Border Grid.Column="3" Classes="metric" Margin="0">
@@ -227,10 +231,58 @@
<Border Classes="sidePanel"> <Border Classes="sidePanel">
<StackPanel Spacing="9"> <StackPanel Spacing="9">
<TextBlock Text="Tagesüberblick" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Tagesüberblick" FontSize="14" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill"/><TextBlock Grid.Column="1" Text="Anwesend" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding PresentCount}"/><TextBlock Grid.Column="3" Text="{Binding PresentPercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> <!-- Quick-Win: ein gestapelter 100-%-Balken statt vier Einzelzeilen mit
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/><TextBlock Grid.Column="1" Text="Verspätet" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding LateCount}"/><TextBlock Grid.Column="3" Text="{Binding LatePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> separater Prozentspalte — Verteilung auf einen Blick statt aus vier
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/><TextBlock Grid.Column="1" Text="Fehlzeit · entschuldigt" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding ExcusedAbsenceCount}"/><TextBlock Grid.Column="3" Text="{Binding ExcusedAbsencePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> Prozentzahlen erschlossen. Jedes Segment multipliziert per MultiBinding
<Grid ColumnDefinitions="10,*,Auto,40"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/><TextBlock Grid.Column="1" Text="Fehlzeit · unentschuldigt" FontSize="12"/><TextBlock Grid.Column="2" Text="{Binding UnexcusedAbsenceCount}"/><TextBlock Grid.Column="3" Text="{Binding UnexcusedAbsencePercent, StringFormat='{}{0} %'}" Opacity="0.6" HorizontalAlignment="Right"/></Grid> die gerenderte Breite der Track-Leiste (x:Name="DayOverviewTrack") mit
seinem Anteil 0…1 (FractionWidthConverter) — bleibt dadurch korrekt, egal
wie breit die Seitenspalte in XAML gerade ist. -->
<Grid Height="10" ToolTip.Tip="{Binding DayOverviewTooltip}">
<Border x:Name="DayOverviewTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
CornerRadius="5"/>
<Border CornerRadius="5" ClipToBounds="True" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal">
<Border Classes="statusFill">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="PresentFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill warning">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="LateFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill info">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="ExcusedAbsenceFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill danger">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="DayOverviewTrack" Path="Bounds.Width"/>
<Binding Path="UnexcusedAbsenceFraction"/>
</MultiBinding>
</Border.Width>
</Border>
</StackPanel>
</Border>
</Grid>
<StackPanel Spacing="5">
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill"/><TextBlock Text="{Binding PresentSummaryLabel}" FontSize="12"/></StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill warning"/><TextBlock Text="{Binding LateSummaryLabel}" FontSize="12"/></StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill info"/><TextBlock Text="{Binding ExcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"><Border Width="7" Height="7" CornerRadius="4" Classes="statusFill danger"/><TextBlock Text="{Binding UnexcusedAbsenceSummaryLabel}" FontSize="12"/></StackPanel>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -242,17 +294,50 @@
<DataTemplate x:DataType="vm:ClassTeacherTrendDay"> <DataTemplate x:DataType="vm:ClassTeacherTrendDay">
<Grid ColumnDefinitions="42,*,24" Margin="0,2"> <Grid ColumnDefinitions="42,*,24" Margin="0,2">
<TextBlock Text="{Binding DayLabel}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/> <TextBlock Text="{Binding DayLabel}" FontSize="11" Opacity="0.6" VerticalAlignment="Center"/>
<Grid Grid.Column="1" Height="8" VerticalAlignment="Center"> <Grid Grid.Column="1" Height="8" VerticalAlignment="Center"
<Border Background="{DynamicResource AppTrackBackgroundBrush}" CornerRadius="4"/> ToolTip.Tip="{Binding DetailTooltip}">
<Border x:Name="TrendTrack" Background="{DynamicResource AppTrackBackgroundBrush}"
CornerRadius="4"/>
<!-- Gestapelter Balken: drei sich nicht überschneidende Segmente <!-- Gestapelter Balken: drei sich nicht überschneidende Segmente
(siehe ClassTeacherTrendDay), gemeinsam auf CornerRadius (siehe ClassTeacherTrendDay). Jedes Segment multipliziert per
zugeschnitten statt jedes Segment einzeln abzurunden. --> MultiBinding die gerenderte Breite von "TrendTrack" mit seinem
<Border Width="{Binding TotalBarWidth}" HorizontalAlignment="Left" Anteil 0…1 (FractionWidthConverter) — bleibt damit auch dann
CornerRadius="4" ClipToBounds="True"> 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. -->
<Border CornerRadius="4" ClipToBounds="True" HorizontalAlignment="Left">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="TotalFraction"/>
</MultiBinding>
</Border.Width>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Border Width="{Binding UnexcusedBarWidth}" Classes="statusFill danger"/> <Border Classes="statusFill danger">
<Border Width="{Binding LateExcusedBarWidth}" Classes="statusFill warning"/> <Border.Width>
<Border Width="{Binding ExcusedBarWidth}" Classes="statusFill info"/> <MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="UnexcusedFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill warning">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="LateExcusedFraction"/>
</MultiBinding>
</Border.Width>
</Border>
<Border Classes="statusFill info">
<Border.Width>
<MultiBinding Converter="{x:Static conv:FractionWidthConverter.Instance}">
<Binding ElementName="TrendTrack" Path="Bounds.Width"/>
<Binding Path="ExcusedFraction"/>
</MultiBinding>
</Border.Width>
</Border>
</StackPanel> </StackPanel>
</Border> </Border>
</Grid> </Grid>
+39 -4
View File
@@ -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 lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe
Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und
Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt
hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`), hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`);
deren Breiten sich exakt zu `TotalBarWidth` summieren; in XAML als `Border ClipToBounds="True"` in XAML als `Border ClipToBounds="True"` mit `CornerRadius` um eine horizontale `StackPanel`
mit `CornerRadius` um eine horizontale `StackPanel` aus drei quadratischen Segmenten gerendert, aus drei Segmenten gerendert, damit nur die äußeren Kanten abgerundet sind. Wie die
damit nur die äußeren Kanten abgerundet sind. 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 - **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 Balken bislang systematisch leer, weil `start.AddDays(offset)` Wochenenden mitzählte. Neues
`ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne `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). Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen).
Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection, Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection,
Service war bereits als Singleton registriert). 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 ### 4.4 Wochen-/Tagesansicht