Klassenlehrer-Tab: vier Feature-Ideen umgesetzt (ohne Elterngesprächs-PDF)

Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach
Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im
Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und
eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile
eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel
verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend.

ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias
$parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) —
stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als
PDF bewusst zurückgestellt, Konzept noch nicht geschärft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:57:09 +02:00
co-authored by Claude Sonnet 5
parent 3f8dab4abd
commit 3f484ce3c8
6 changed files with 537 additions and 7 deletions
@@ -264,6 +264,149 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(row.StatusText, row.StatusTextWithGlyph); Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
} }
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact]
public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
// StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname",
// hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow).
Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName));
Assert.Equal(6, rows[0].DaysOpen);
Assert.True(rows[0].IsOverdue);
Assert.Equal(1, rows[1].DaysOpen);
Assert.False(rows[1].IsOverdue);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact]
public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler()
{
var entries = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test",
"Lob", "Positiv", "Gute Mitarbeit"),
};
var rows = ClassTeacherCategoryAggregateRow.Build(entries);
Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName));
var homework = rows[0];
Assert.Equal(3, homework.Count);
Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel);
Assert.Contains("1× Ada Müller", homework.TopStudentsLabel);
}
// ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ──────
[Fact]
public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag()
{
// 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string>());
var notice = Assert.Single(notices);
Assert.Equal("Mona Muster", notice.StudentName);
Assert.Contains("Montag", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind);
}
[Fact]
public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, new Dictionary<string, string>(), new HashSet<string>());
Assert.Empty(notices);
}
[Fact]
public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string> { "Mona Muster" });
Assert.Empty(notices);
}
// ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ─────────────
[Fact]
public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName()
{
// Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde
// UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent
// muss deshalb FirstName/LastName getrennt verwenden, nicht FullName.
var students = new List<Student>
{
new() { FirstName = "Ada", LastName = "Müller" },
new() { FirstName = "Ben", LastName = "Schmidt" },
};
var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students);
Assert.NotNull(match);
Assert.Equal("Ada", match!.FirstName);
}
[Fact]
public void MatchStudent_LiefertNullOhneTreffer()
{
var students = new List<Student> { new() { FirstName = "Ada", LastName = "Müller" } };
Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students));
}
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
} }
@@ -26,6 +26,33 @@ public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject
} }
} }
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
{
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
.GroupBy(e => e.CategoryName!)
.Select(g =>
{
var topStudents = g.GroupBy(e => e.StudentDisplayName)
.Select(sg => (Name: sg.Key, Count: sg.Count()))
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
.Take(topStudentsPerCategory)
.Select(sg => $"{sg.Count}× {sg.Name}");
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
})
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
.ToList();
}
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst - /// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere /// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt /// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
@@ -107,6 +134,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = []; public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = []; public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6); [ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now; [ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
@@ -118,6 +146,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public bool HasEntries => Entries.Count > 0; public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0; public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter) public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter; ? "Alle Schüler*innen" : StudentFilter;
@@ -132,6 +161,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
StudentFilter = ""; StudentFilter = "";
Entries.Clear(); Entries.Clear();
AbsenceEntries.Clear(); AbsenceEntries.Clear();
CategoryAggregates.Clear();
Status = "Zeitraum wählen und laden."; Status = "Zeitraum wählen und laden.";
NotifyListState(); NotifyListState();
} }
@@ -170,7 +200,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; } if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; } if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); NotifyListState(); Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
try try
{ {
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh); var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
@@ -187,6 +217,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName, Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text)); entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
} }
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
var absences = absencesTask.Result.Where(MatchesStudentFilter); var absences = absencesTask.Result.Where(MatchesStudentFilter);
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences)) foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
@@ -216,5 +247,6 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
{ {
OnPropertyChanged(nameof(HasEntries)); OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries)); OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(HasCategoryAggregates));
} }
} }
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
@@ -163,11 +164,40 @@ public sealed record ClassTeacherPatternNotice(string StudentName, string Messag
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info; public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
} }
/// <summary>Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird —
/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend,
/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin
/// für die Jahresfehlquote geladenen Daten (<see cref="ClassTeacherOverviewViewModel.Load"/>),
/// kein zusätzlicher Abruf.</summary>
public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen)
{
/// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen"
/// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische
/// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche
/// Regelung kann je nach Bundesland/Schulordnung abweichen.
public const int DeadlineDays = 3;
public string DateLabel => Date.ToString("dd.MM.");
public bool IsOverdue => DaysOpen > DeadlineDays;
public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt";
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
absenceDays.Where(a => a.IsUnexcused)
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
today.DayNumber - a.Date.DayNumber))
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
.ToList();
}
public partial class ClassTeacherOverviewViewModel : ObservableObject public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
private readonly WebUntisSettingsService _settings; private readonly WebUntisSettingsService _settings;
private readonly UntisReportCacheService _cache; private readonly UntisReportCacheService _cache;
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
private readonly IWorkTaskRepository _workTasks;
private readonly IStudentRepository _students;
private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions;
public ClassTeacherDetailsViewModel DetailsTab { get; } public ClassTeacherDetailsViewModel DetailsTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
@@ -175,6 +205,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = []; public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = []; public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
[ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex; [ObservableProperty] private int _activeTabIndex;
@@ -193,12 +224,15 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[ObservableProperty] private int _excusedAbsenceCount; [ObservableProperty] private int _excusedAbsenceCount;
[ObservableProperty] private int _unexcusedAbsenceCount; [ObservableProperty] private int _unexcusedAbsenceCount;
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert"; [ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
[ObservableProperty] private int _openExcuseOverflowCount;
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
public bool HasPrimaryRoster => PrimaryRoster.Count > 0; public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
public bool HasSecondaryRoster => SecondaryRoster.Count > 0; public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0; public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
public bool HasPatternNotices => PatternNotices.Count > 0; public bool HasPatternNotices => PatternNotices.Count > 0;
public bool HasOpenExcuses => OpenExcuses.Count > 0;
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool AlertsFilterSelected => SelectedRosterFilter == 0;
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
public bool AllFilterSelected => SelectedRosterFilter == 2; public bool AllFilterSelected => SelectedRosterFilter == 2;
@@ -235,15 +269,22 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
UntisReportCacheService cache, SchoolYearService schoolYear, ClassTeacherDetailsViewModel detailsTab) UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
{ {
_settings = settings; _settings = settings;
_cache = cache; _cache = cache;
_schoolYear = schoolYear; _schoolYear = schoolYear;
_workTasks = workTasks;
_students = students;
_participation = participation;
_participationSessions = participationSessions;
DetailsTab = detailsTab; DetailsTab = detailsTab;
} }
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value) partial void OnSelectedRosterFilterChanged(int value)
{ {
@@ -259,6 +300,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
HomeroomClassName = _settings.HomeroomClassName; HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0; ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
if (!HomeroomClassConfigured) if (!HomeroomClassConfigured)
{ {
Status = "Noch keine Klasse ausgewählt."; Status = "Noch keine Klasse ausgewählt.";
@@ -302,6 +344,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
RecentClassRegisterCount = classRegisterTask.Result.Count; RecentClassRegisterCount = classRegisterTask.Result.Count;
BuildTrend(absenceDaysYear, trendDays); BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(weekAbsenceDays); BuildPatternNotices(weekAbsenceDays);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildOpenExcuses(absenceDaysYear, today);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter(); ApplyRosterFilter();
@@ -436,6 +481,174 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
OnPropertyChanged(nameof(HasPatternNotices)); OnPropertyChanged(nameof(HasPatternNotices));
} }
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
/// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren
/// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares
/// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7
/// Tagen. Wer schon eine Notiz aus <see cref="BuildPatternNotices"/> hat, wird hier
/// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt.
private void BuildWeekdayPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
{
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
.ToDictionary(g => g.Key, g => g.First().StudentName);
var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet();
foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted))
PatternNotices.Add(notice);
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie
/// <see cref="ClassTeacherRosterRow.Build"/>). <paramref name="minSampleSize"/> verhindert,
/// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten;
/// <paramref name="clusterThreshold"/> verlangt eine deutliche Häufung, nicht nur eine leichte
/// Mehrheit.
public static IReadOnlyList<ClassTeacherPatternNotice> DetectWeekdayPatterns(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays,
IReadOnlyDictionary<string, string> displayNamesByKey,
ISet<string> excludeDisplayNames,
int minSampleSize = 3, double clusterThreshold = 0.6)
{
var notices = new List<ClassTeacherPatternNotice>();
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
{
var rows = group.ToList();
if (rows.Count < minSampleSize) continue;
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
if (excludeDisplayNames.Contains(displayName)) continue;
var top = rows.GroupBy(r => r.Date.DayOfWeek)
.Select(g => (Day: g.Key, Count: g.Count()))
.OrderByDescending(g => g.Count).First();
if ((double)top.Count / rows.Count < clusterThreshold) continue;
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}",
ClassTeacherStatusKind.Info));
}
return notices;
}
/// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das
/// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote
/// (<see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/>) mit dem Mitarbeit-Trend aus
/// dem Dokumentations-/Mitarbeitsmodul (<see cref="IParticipationRepository"/>, gruppen-
/// übergreifend über <c>GetByStudent</c>, gleiches Muster wie die Fehlzeitenbilanz in
/// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote
/// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche
/// simple Heuristik wie <c>ParticipationGradeRow.ComputeTrend</c>), sonst würde die Liste bei
/// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb
/// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden
/// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow):
/// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt
/// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist
/// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
private const int CorrelationAbsenceRateThreshold = 15;
private void BuildAttendanceParticipationNotices()
{
var students = _students.GetAll();
foreach (var row in Roster.Where(r => r.HasYearSummary &&
r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold))
{
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
var student = MatchStudent(row.StudentName, students);
if (student is null) continue;
var points = _participation.GetByStudent(student.Id)
.Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e))
.Where(x => x.Session is not null && x.Entry.Ratings.Count > 0)
.OrderBy(x => x.Session!.Date)
.TakeLast(8)
.Select(x => x.Entry.Ratings.Average(r => r.Value))
.ToList();
if (points.Count < 4) continue;
var mid = points.Count / 2;
var diff = points.Skip(mid).Average() - points.Take(mid).Average();
if (diff <= -0.4)
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
$"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit",
ClassTeacherStatusKind.Info));
}
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
/// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen).
public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList<Student> students)
{
var key = UntisNameMatching.NameKey(webUntisDisplayName);
return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key);
}
private static string WeekdayLabel(DayOfWeek day) => day switch
{
DayOfWeek.Monday => "Montag",
DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch",
DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag",
DayOfWeek.Saturday => "Samstag",
_ => "Sonntag",
};
/// Zeigt höchstens <see cref="OpenExcuseDisplayLimit"/> Zeilen — Nutzer-Feedback zu den
/// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest
/// zählt in <see cref="OpenExcuseOverflowCount"/> und wird als "+N weitere" angezeigt.
private const int OpenExcuseDisplayLimit = 6;
private void BuildOpenExcuses(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today)
{
var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today);
foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row);
OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit);
}
[RelayCommand]
private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row)
{
if (row is null) return;
DetailsTab.StudentFilter = row.StudentName;
ActiveTabIndex = 2;
DetailsTab.LoadCommand.Execute(null);
}
/// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche
/// Erstellen ("Eltern anrufen Ada Müller") musste man dort noch einmal von Hand eintippen.
/// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen —
/// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe
/// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist
/// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben).
[RelayCommand]
private void CreateReminderForStudent(ClassTeacherRosterRow? row)
{
if (row is null) return;
SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused);
}
[RelayCommand]
private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row)
{
if (row is null) return;
SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).",
urgent: row.IsOverdue);
}
private void SaveReminder(string studentName, string notes, bool urgent)
{
var task = new WorkTask
{
Title = $"Eltern kontaktieren {studentName}",
Notes = notes,
Kind = TaskKind.Reminder,
Priority = urgent ? TaskPriority.High : TaskPriority.Normal,
DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1),
};
_workTasks.Save(task);
Status = $"Wiedervorlage „{task.Title}\" angelegt zu finden unter Aufgaben & Wiedervorlagen.";
}
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount); private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
private void NotifySummary() private void NotifySummary()
{ {
@@ -452,6 +665,6 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private void NotifyRosterState() private void NotifyRosterState()
{ {
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster)); OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses));
} }
} }
@@ -44,6 +44,21 @@
<Style Selector="Button.rosterRow:pointerover /template/ ContentPresenter"> <Style Selector="Button.rosterRow:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/> <Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
</Style> </Style>
<Style Selector="Button.rosterRowAction">
<Setter Property="Width" Value="30"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Background" Value="{DynamicResource AppListRowBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppListRowBorderBrush}"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
<Setter Property="Foreground" Value="{DynamicResource AppAccentTextBrush}"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style Selector="Button.rosterRowAction:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AppListRowHoverBrush}"/>
</Style>
<Style Selector="Border.sidePanel"> <Style Selector="Border.sidePanel">
<Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/> <Setter Property="Background" Value="{DynamicResource AppCardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/> <Setter Property="BorderBrush" Value="{DynamicResource AppCardBorderBrush}"/>
@@ -89,7 +104,13 @@
<!-- Eine Vorlage für beide Roster-Listen (auffällig / übrige): Sie waren Zeichen für Zeichen <!-- Eine Vorlage für beide Roster-Listen (auffällig / übrige): Sie waren Zeichen für Zeichen
identisch und mussten bisher doppelt gepflegt werden. --> identisch und mussten bisher doppelt gepflegt werden. -->
<DataTemplate x:Key="RosterRowTemplate" x:DataType="vm:ClassTeacherRosterRow"> <DataTemplate x:Key="RosterRowTemplate" x:DataType="vm:ClassTeacherRosterRow">
<Button Classes="rosterRow" <!-- Zwei Geschwister-Controls statt eines ContextMenu auf dem Zeilen-Button: Avalonia löst
$parent-Vorfahrenpfade in einem ContextMenu nicht zuverlässig auf, weil dessen Popup
nicht im normalen visuellen Baum unter dem ItemsControl hängt. Ein separater kleiner
Button daneben bleibt im normalen Baum und kann so ganz regulär auf das ViewModel des
ItemsControl zugreifen. -->
<Grid ColumnDefinitions="*,Auto">
<Button Grid.Column="0" Classes="rosterRow"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}" Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForStudentCommand}"
CommandParameter="{Binding}"> CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,42,220,*,150,18"> <Grid ColumnDefinitions="4,42,220,*,150,18">
@@ -127,6 +148,13 @@
<TextBlock Grid.Column="5" Text="" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/> <TextBlock Grid.Column="5" Text="" FontSize="18" Opacity="0.7" VerticalAlignment="Center"/>
</Grid> </Grid>
</Button> </Button>
<!-- Quick-Win: "Eltern kontaktieren" als vorbefüllte Wiedervorlage direkt aus der Zeile
anlegen, statt erst ins Aufgaben-Modul zu wechseln und von Hand einzutragen. -->
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren&#8220; anlegen"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForStudentCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate> </DataTemplate>
</UserControl.Resources> </UserControl.Resources>
@@ -369,6 +397,42 @@
</StackPanel> </StackPanel>
</Border> </Border>
<Border Classes="sidePanel" IsVisible="{Binding HasOpenExcuses}">
<StackPanel Spacing="8">
<TextBlock Text="Offene Entschuldigungen" FontSize="14" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding OpenExcuses}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherOpenExcuseRow">
<Grid ColumnDefinitions="*,Auto">
<Button Grid.Column="0" Classes="rosterRow" Padding="6,5"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).ShowDetailsForOpenExcuseCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="4,*,Auto" Margin="0,2">
<Border Grid.Column="0" CornerRadius="2" Margin="0,1,8,1"
Classes="statusFill" Classes.warning="{Binding !IsOverdue}"
Classes.danger="{Binding IsOverdue}"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding DaysOpenLabel}" FontSize="11" Opacity="0.62"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding DateLabel}" FontSize="11"
Opacity="0.5" VerticalAlignment="Center"/>
</Grid>
</Button>
<Button Grid.Column="1" Classes="rosterRowAction" Content="+"
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren&#8220; anlegen"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForOpenExcuseCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock FontSize="11" Opacity="0.5" IsVisible="{Binding HasOpenExcuseOverflow}">
<Run Text="+"/><Run Text="{Binding OpenExcuseOverflowCount}"/><Run Text=" weitere"/>
</TextBlock>
</StackPanel>
</Border>
<Border Classes="sidePanel" IsVisible="{Binding HasPatternNotices}"> <Border Classes="sidePanel" IsVisible="{Binding HasPatternNotices}">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Text="Muster erkannt" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Muster erkannt" FontSize="14" FontWeight="SemiBold"/>
@@ -28,7 +28,7 @@
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16" RowSpacing="10"> <Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="16" RowSpacing="10">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel> <StackPanel>
<TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/> <TextBlock Text="Klassenbucheinträge" FontSize="22" FontWeight="SemiBold"/>
@@ -58,7 +58,35 @@
</Grid> </Grid>
</Border> </Border>
<Grid Grid.Row="2"> <!-- Nutzer-Feedback: die reine Gesamtzahl der Einträge sagt wenig, eine Aufschlüsselung nach
Kategorie ("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller") ist die eigentlich
interessante Information. Als Chip-Reihe statt eigener Spalte in der Tabelle, damit die
bestehenden Spalten unangetastet bleiben. -->
<Border Grid.Row="2" Classes="filterCard" IsVisible="{Binding HasCategoryAggregates}">
<StackPanel Spacing="6">
<TextBlock Text="Kategorien im Zeitraum" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CategoryAggregates}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherCategoryAggregateRow">
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="5"
Padding="8,4" Margin="0,0,6,6">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding CategoryName}" FontSize="11" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SummaryLabel}" FontSize="11" Opacity="0.6"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Grid Grid.Row="3">
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True" <DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" GridLinesVisibility="Horizontal" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46"> CanUserResizeColumns="True" CanUserSortColumns="True" RowHeight="46">
@@ -77,6 +105,6 @@
<TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/> <TextBlock Text="Passe Zeitraum oder Schülerfilter an." FontSize="12" Opacity="0.55"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<TextBlock Grid.Row="3" Text="{Binding Status}" FontSize="11" Opacity="0.6"/> <TextBlock Grid.Row="4" Text="{Binding Status}" FontSize="11" Opacity="0.6"/>
</Grid> </Grid>
</UserControl> </UserControl>
+50
View File
@@ -1479,6 +1479,56 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Gefahr > Warnung > Info > Ok). Da die Farben über die geteilten `AppStatus*Brush`-Ressourcen Gefahr > Warnung > Info > Ok). Da die Farben über die geteilten `AppStatus*Brush`-Ressourcen
laufen, wirkt die Korrektur automatisch überall im Klassenlehrer-Bereich (Kennzahlkarten, laufen, wirkt die Korrektur automatisch überall im Klassenlehrer-Bereich (Kennzahlkarten,
Roster-Status, Chips in Fehlzeiten-/Klassenbuchansicht), nicht nur in den beiden Charts. Roster-Status, Chips in Fehlzeiten-/Klassenbuchansicht), nicht nur in den beiden Charts.
- [x] **"Klassenlehrer"-Feature — vier Feature-Ideen umgesetzt (August 2026):** Aus derselben
Vorschlagsliste wie die Quick-Wins oben, auf Nutzerwunsch alle außer dem Elterngesprächs-PDF
(dafür fehlt noch ein geschärftes Konzept).
- **Offene Entschuldigungen mit Frist:** neue Karte in der Seitenspalte, listet aktuell
unentschuldigte Fehltage über den gesamten geladenen Zeitraum (nicht nur heute), sortiert nach
Tagen offen absteigend, auf 6 Zeilen gedeckelt ("+N weitere"). `ClassTeacherOpenExcuseRow`
markiert Einträge über `DeadlineDays = 3` als überfällig (Richtwert für die häufige
"innerhalb von drei Tagen"-Regelung, z.B. § 43 Abs. 2 SchulG NRW — keine rechtlich geprüfte,
schulspezifische Frist, bei Bedarf anpassen). Läuft über die ohnehin für die Jahresfehlquote
geladenen Daten, kein zusätzlicher Abruf.
- **Aggregierte Klassenbuch-Kategorien:** neue Chip-Reihe über der Tabelle im Klassenbuch-Tab
("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller" statt nur einer Gesamtzahl).
`ClassTeacherCategoryAggregateRow.Build` gruppiert die bereits geladenen `Entries` nach
Kategorie, rein clientseitig.
- **Erweiterte Mustererkennung — Wochentags-Häufung:** dritte Regel neben den bestehenden
(≥2 unentschuldigt/verspätet in 7 Tagen): wenn ≥60 % der Fehltage eines/einer Schülers/-in
seit Schuljahresbeginn (Mindeststichprobe 3) auf denselben Wochentag fallen, ein Hinweis
("3 von 4 Fehltagen an einem Montag"). Wer schon eine Notiz aus der bestehenden Regel hat,
wird ausgelassen, damit niemand doppelt auftaucht. Kernlogik als
`ClassTeacherOverviewViewModel.DetectWeekdayPatterns` bewusst statisch/ohne ViewModel-Zustand
(gleiches Muster wie `ClassTeacherRosterRow.Build`) und dadurch direkt testbar.
- **Aufgabe/Wiedervorlage direkt aus der Zeile:** neuer kleiner "+"-Button neben jeder
Roster-Zeile und jeder Zeile in "Offene Entschuldigungen" legt sofort eine `WorkTask`
(`Kind = Reminder`, wie 6.1.1, bewusst ohne `GroupId` — Klassenlehrer ist man für die ganze
Klasse) mit vorbefülltem Titel "Eltern kontaktieren {Name}" an, ohne Dialog. **Wichtiger
XAML-Fallstrick dabei:** ein `ContextMenu` auf dem Zeilen-`Button` war der naheliegendere
erste Ansatz, scheitert aber an `$parent`-Vorfahren-Bindings — das `ContextMenu`-Popup hängt
nicht im normalen visuellen Baum unter dem `ItemsControl`, `$parent[ItemsControl]` findet das
ViewModel dort nicht. Stattdessen zwei Geschwister-`Button` in einem `Grid` (der bestehende
Zeilen-Button bleibt unverändert, der neue "+"-Button daneben hat ganz regulären
Vorfahren-Zugriff).
- **Verknüpfung mit Noten/Mitarbeit:** bewusst als dritte Regel im "Muster erkannt"-Panel statt
als Dauerzusatz in jeder Zeile (Overload vermeiden) — nur bei bereits spürbar erhöhter
Jahresfehlquote (≥15 %) UND eindeutig fallendem Mitarbeit-Trend über die letzten bis zu 8
Bewertungen (gleiche einfache Erste-Hälfte/Zweite-Hälfte-Heuristik wie
`ParticipationGradeRow.ComputeTrend`). Namensabgleich WebUntis→internem `Student` über
`ClassTeacherOverviewViewModel.MatchStudent` (öffentlich statt `internal`, wie der Rest der
Codebasis es hält, siehe `UntisSyncService`-Kommentar — kein `InternalsVisibleTo`
konfiguriert). **Wichtiger Fallstrick:** `Student.FullName` liefert "Nachname, Vorname" mit
Komma — ein Abgleich darüber hätte den leerzeichenbasierten Wortvergleich in
`UntisNameMatching` verfälscht ("Müller," bliebe eigenes Wort). `MatchStudent` baut den
Vergleichsnamen deshalb explizit aus `FirstName`/`LastName` zusammen, nicht aus `FullName`.
Mitarbeit-Rohwerte werden bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in
`ParticipationGradeViewModels`) — die Aspekt-Konfiguration ist je Lerngruppe, der
Klassenlehrer-Bereich kennt aber keine einzelne Gruppe; ein einfacher Rohwert-Durchschnitt ist
hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
Neue Abhängigkeiten in `ClassTeacherOverviewViewModel`: `IWorkTaskRepository`,
`IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon
als Singleton registriert, nur Konstruktor-Injection ergänzt).
**Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug.
### 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