Add Feature: Monatsübersicht Fehlzeiten Widget
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-06 13:27:52 +02:00
parent 265190b3aa
commit 73453fd88d
4 changed files with 190 additions and 26 deletions
@@ -39,6 +39,38 @@ public sealed class ClassTeacherViewModelsTests
Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H");
}
[Fact]
public void Kompaktkalender_ZeigtNurMonatstageUndJeTagDasStaerksteSignal()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260903, "Deu", "Müller Ada", "test",
"Störung", "Negativ", "Unterricht gestört"),
new UntisForeignClassRegisterEventDto("6a", 20260905, "Mathe", "Schmidt Ben", "test",
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
};
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, register,
new DateOnly(2026, 9, 3));
Assert.Equal(30, days.Count);
Assert.False(days[0].HasSignal);
Assert.Equal("1", days[0].DayNumber);
Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
Assert.True(days[2].IsToday);
Assert.Contains("Ada Müller", days[2].Tooltip);
Assert.Equal("E", days[3].SignalCode);
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
}
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
@@ -379,35 +379,42 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
continue;
}
var events = new List<ClassTeacherCalendarEvent>();
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
{
var (code, label, color, kind) = absence.IsUnexcused
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
: absence.IsLate
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
}
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
{
var isHomework = ContainsHomework(entry);
if (isHomework && !includeHomework) continue;
if (!entry.IsDangerStatus && !isHomework) continue;
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
isHomework ? "#6A1B9A" : "#8E24AA",
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
}
result.Add(new ClassTeacherCalendarDay(date, events));
result.Add(new ClassTeacherCalendarDay(date,
BuildDayEvents(date, absences, registerEntries, includeHomework)));
}
return result;
}
public static IReadOnlyList<ClassTeacherCalendarEvent> BuildDayEvents(DateOnly date,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
{
var events = new List<ClassTeacherCalendarEvent>();
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
{
var (code, label, color, kind) = absence.IsUnexcused
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
: absence.IsLate
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
}
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
{
var isHomework = ContainsHomework(entry);
if (isHomework && !includeHomework) continue;
if (!entry.IsDangerStatus && !isHomework) continue;
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
isHomework ? "#6A1B9A" : "#8E24AA",
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
}
return events;
}
private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) =>
entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true;
@@ -392,6 +392,14 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
.ToList();
}
/// <summary>Eine einzelne, auf ein Minimum verdichtete Monatszelle im Übersichts-Widget.</summary>
public sealed record ClassTeacherCompactCalendarDay(DateOnly Date, string SignalCode,
string SignalColorHex, string Tooltip, bool IsToday)
{
public string DayNumber => Date.Day.ToString();
public bool HasSignal => !string.IsNullOrEmpty(SignalCode);
}
public partial class ClassTeacherOverviewViewModel : ObservableObject
{
private readonly WebUntisSettingsService _settings;
@@ -415,6 +423,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
[ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex;
@@ -492,6 +501,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public string CompactMonthLabel => DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; }
@@ -537,7 +548,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
OpenExcuses.Clear(); CompactMonthDays.Clear(); OpenExcuseOverflowCount = 0;
}
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
@@ -676,6 +687,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = recentRegisterEntries.Count;
BuildTrend(absenceDaysYear, trendDays);
BuildCompactMonth(absenceDaysYear, classRegisterEvents, today);
BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
@@ -708,6 +720,64 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand]
private void OpenMonthlyCalendar()
{
var today = DateOnly.FromDateTime(DateTime.Today);
DetailsTab.StudentFilter = "";
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
ActiveTabIndex = 2;
DetailsTab.ShowCalendarCommand.Execute(null);
}
private void BuildCompactMonth(IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
{
CompactMonthDays.Clear();
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
absences, registerEntries, today))
CompactMonthDays.Add(day);
}
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
{
var first = new DateOnly(month.Year, month.Month, 1);
var registerRows = registerEntries
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
.Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName,
x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text))
.ToList();
var result = new List<ClassTeacherCompactCalendarDay>();
var days = DateTime.DaysInMonth(first.Year, first.Month);
for (var number = 1; number <= days; number++)
{
var date = new DateOnly(first.Year, first.Month, number);
var events = ClassTeacherDetailsViewModel.BuildDayEvents(date, absences, registerRows,
includeHomework: false);
var strongest = events.OrderBy(e => SignalPriority(e.Kind)).FirstOrDefault();
var tooltip = events.Count == 0
? $"{date:dd.MM.yyyy}: unauffällig"
: $"{date:dd.MM.yyyy}\n" + string.Join("\n", events.Select(e => e.Tooltip));
result.Add(new ClassTeacherCompactCalendarDay(date, strongest?.Code ?? "",
strongest?.ColorHex ?? "#00000000", tooltip, date == today));
}
return result;
}
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
{
ClassTeacherCalendarEventKind.Unexcused => 0,
ClassTeacherCalendarEventKind.ClassRegister => 1,
ClassTeacherCalendarEventKind.Absent => 2,
ClassTeacherCalendarEventKind.Late => 3,
ClassTeacherCalendarEventKind.Excused => 4,
_ => 5,
};
[RelayCommand]
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
{
@@ -98,6 +98,19 @@
<Style Selector="Border.statusFill.danger">
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="Border.compactDay">
<Setter Property="Width" Value="28"/>
<Setter Property="Height" Value="28"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Background" Value="{DynamicResource AppChipBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Margin" Value="2"/>
</Style>
<Style Selector="Border.compactDay.today">
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentTextBrush}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
</UserControl.Styles>
<UserControl.Resources>
@@ -409,6 +422,48 @@
</StackPanel>
</Border>
<!-- Verdichtete Monatsansicht: exakt eine Zelle je Kalendertag. Ohne Ereignis
bleibt nur die Tageszahl sichtbar; bei Auffälligkeiten ersetzt das stärkste
Statuskürzel die Zahl. Alle Details bleiben per Tooltip und Drill-down erhalten. -->
<Border Classes="sidePanel">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="Monat auf einen Blick" FontSize="14" FontWeight="SemiBold"/>
<TextBlock Text="{Binding CompactMonthLabel}" FontSize="11" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="Öffnen " Command="{Binding OpenMonthlyCalendarCommand}"
Background="Transparent" BorderThickness="0" Padding="6,2"
Foreground="{DynamicResource AppAccentTextBrush}"/>
</Grid>
<ItemsControl ItemsSource="{Binding CompactMonthDays}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="7"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCompactCalendarDay">
<Border Classes="compactDay" Classes.today="{Binding IsToday}"
ToolTip.Tip="{Binding Tooltip}">
<Grid>
<TextBlock Text="{Binding DayNumber}" FontSize="11" Opacity="0.7"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !HasSignal}"/>
<Border Background="{Binding SignalColorHex}" CornerRadius="5"
IsVisible="{Binding HasSignal}">
<TextBlock Text="{Binding SignalCode}" Foreground="White" FontSize="11"
FontWeight="Bold" HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="U unentsch. · ! Klassenbuch · A abwesend · V verspätet · E entsch."
FontSize="9" Opacity="0.5" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}">
<StackPanel Spacing="8">
<TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/>