diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index e9af6a8..25db686 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -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() { diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index 4707ad7..ad9854e 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -379,35 +379,42 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject continue; } - var events = new List(); - 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 BuildDayEvents(DateOnly date, + IReadOnlyList absences, + IReadOnlyList registerEntries, bool includeHomework) + { + var events = new List(); + 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; diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 2f2fef2..9e84708 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -392,6 +392,14 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date .ToList(); } +/// Eine einzelne, auf ein Minimum verdichtete Monatszelle im Übersichts-Widget. +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 TrendDays { get; } = []; public ObservableCollection PatternNotices { get; } = []; public ObservableCollection OpenExcuses { get; } = []; + public ObservableCollection 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? OnNavigateToSettings { get; set; } public Func? 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 absences, + IReadOnlyList 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 BuildCompactMonthDays(DateOnly month, + IReadOnlyList absences, + IReadOnlyList 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(); + 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) { diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index 20abba8..6bae859 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -98,6 +98,19 @@ + + @@ -409,6 +422,48 @@ + + + + + + + + +