From 84c03962ef78f7d848dcfb4970f14a5ff1d56a0d Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 30 Aug 2026 02:33:07 +0200 Subject: [PATCH] Klausurschnitt-Vorjahresvergleich beim Hochstufen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LearningGroup.PreviousGroupId wird von GroupRolloverService jetzt automatisch gesetzt; neue Karte "Vorjahresvergleich" im Kurs-Tab Übersicht zeigt den gepoolten Klausurdurchschnitt neben dem der Vorgängergruppe, sobald dort Ergebnisse vorliegen. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Models/LearningGroup.cs | 7 ++ .../Services/GroupRolloverService.cs | 1 + .../GroupDetailViewModelTests.cs | 4 +- .../GroupOverviewViewModelTests.cs | 89 ++++++++++++++++++- .../Groups/GroupOverviewViewModel.cs | 49 ++++++++-- .../Views/Groups/GroupOverviewTabView.axaml | 8 ++ LehrerApp.Tests/GroupRolloverServiceTests.cs | 1 + TODO.md | 13 +++ 8 files changed, 162 insertions(+), 10 deletions(-) diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs index c2afd80..a4a0a89 100644 --- a/LehrerApp.Core/Models/LearningGroup.cs +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -26,6 +26,13 @@ public class LearningGroup /// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie. /// public int? WebUntisLessonId { get; set; } + /// + /// Vorgängergruppe aus dem Hochstufen () — + /// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg. + /// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds + /// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen). + /// + public Guid? PreviousGroupId { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/LehrerApp.Core/Services/GroupRolloverService.cs b/LehrerApp.Core/Services/GroupRolloverService.cs index b7be352..80e0c62 100644 --- a/LehrerApp.Core/Services/GroupRolloverService.cs +++ b/LehrerApp.Core/Services/GroupRolloverService.cs @@ -60,6 +60,7 @@ public sealed class GroupRolloverService( IsActive = true, IsOwnClass = source.IsOwnClass, IsDifferentiated = source.IsDifferentiated, + PreviousGroupId = source.Id, // WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu, // eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern. }; diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs index 6847b14..eb84f63 100644 --- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests var grades = new FakeGrades(); var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks, - new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students, - new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()), + new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students, + new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), students, memberships, groups, new FakeCompetencyDomains()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), diff --git a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs index 5d49ea2..a37154f 100644 --- a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs @@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests { private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, - FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) => - new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]), + FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, + FakeResults? results = null, FakeGroups? groups = null) => + new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(), + sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), - tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService()); + tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(), + new GradingService(), new SchoolYearService()); private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams, FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario( @@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests Assert.Contains("Klassenarbeit 2", vm.NextExamLabel); } + [Fact] + public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen() + { + var groupId = Guid.NewGuid(); + var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]); + var vm = NewVm(groups: groups); + + vm.Initialize(groupId, "Testkurs"); + + Assert.False(vm.HasYearComparison); + } + + [Fact] + public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen() + { + var previousId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var groups = new FakeGroups([ + new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId }, + new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 }, + ]); + var vm = NewVm(groups: groups); + + vm.Initialize(groupId, "Testkurs"); + + Assert.False(vm.HasYearComparison); + } + + [Fact] + public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn() + { + var previousId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var groups = new FakeGroups([ + new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId }, + new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 }, + ]); + + var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId }; + var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId }; + var exams = new FakeExams([previousExam, currentExam]); + + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" }); + results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" }); + results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" }); + // Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein. + results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" }); + + var vm = NewVm(exams: exams, results: results, groups: groups); + + vm.Initialize(groupId, "Testkurs"); + + Assert.True(vm.HasYearComparison); + Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel); + Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel); + } + + [Fact] + public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr() + { + var previousId = Guid.NewGuid(); + var groupId = Guid.NewGuid(); + var groups = new FakeGroups([ + new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId }, + new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 }, + ]); + var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId }; + var exams = new FakeExams([previousExam]); + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" }); + + var vm = NewVm(exams: exams, results: results, groups: groups); + + vm.Initialize(groupId, "Testkurs"); + + Assert.True(vm.HasYearComparison); + Assert.Contains("noch keine Klausur", vm.YearComparisonLabel); + } + [Fact] public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung() { diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs index 75ac1c2..9962ae2 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs @@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject private readonly ILessonRepository _lessons; private readonly IExamRepository _exams; + private readonly IExamResultRepository _examResults; private readonly IParticipationSessionRepository _sessions; private readonly IParticipationRepository _entries; private readonly IStudentRepository _students; private readonly IDocumentationRepository _documentation; private readonly IWorkTaskRepository _tasks; + private readonly IGroupRepository _groups; private readonly AttendanceBalanceService _attendanceBalance; + private readonly GradingService _grading; private readonly SchoolYearService _schoolYear; private Guid _groupId; @@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject [ObservableProperty] private string _nextLessonLabel = ""; [ObservableProperty] private bool _hasNextExam; [ObservableProperty] private string _nextExamLabel = ""; + [ObservableProperty] private bool _hasYearComparison; + [ObservableProperty] private string _yearComparisonLabel = ""; [ObservableProperty] private string _participationHintLabel = ""; [ObservableProperty] private bool _participationHintIsStale; [ObservableProperty] private bool _hasOpenHomeworkCheck; @@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject } public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams, - IParticipationSessionRepository sessions, IParticipationRepository entries, - IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks, - AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear) + IExamResultRepository examResults, IParticipationSessionRepository sessions, + IParticipationRepository entries, IStudentRepository students, + IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups, + AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear) { - _lessons = lessons; _exams = exams; _sessions = sessions; + _lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions; _entries = entries; _students = students; _documentation = documentation; _tasks = tasks; - _attendanceBalance = attendanceBalance; _schoolYear = schoolYear; + _groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _schoolYear = schoolYear; } public void Initialize(Guid groupId, string groupName) @@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject var today = DateOnly.FromDateTime(DateTime.Today); LoadNextLesson(today); LoadNextExam(today); + LoadYearComparison(); LoadParticipationHint(today); LoadOpenHomeworkCheck(today); LoadOpenExcuses(today); @@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}"; } + // ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ────────────────── + // + // Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe + // hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds + // hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn + // im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich, + // auch wenn eine Verknüpfung besteht. + + private void LoadYearComparison() + { + var group = _groups.GetById(_groupId); + var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null; + if (previous is null) { HasYearComparison = false; return; } + + var current = ComputeExamAverage(_groupId); + YearComparisonLabel = current is null + ? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur." + : $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " + + $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)"; + HasYearComparison = true; + } + + private (double Average, int Count)? ComputeExamAverage(Guid groupId) + { + var grades = _exams.GetByGroup(groupId) + .SelectMany(e => _examResults.GetByExam(e.Id)) + .Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade)) + .Select(r => (Grade: r.Grade!, Weight: 1.0)) + .ToList(); + return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count); + } + /// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung /// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst. private void LoadParticipationHint(DateOnly today) diff --git a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml index 6d21d6c..5660e34 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml @@ -66,6 +66,14 @@ + + + + + + + + diff --git a/LehrerApp.Tests/GroupRolloverServiceTests.cs b/LehrerApp.Tests/GroupRolloverServiceTests.cs index 4011ba9..c7c38e2 100644 --- a/LehrerApp.Tests/GroupRolloverServiceTests.cs +++ b/LehrerApp.Tests/GroupRolloverServiceTests.cs @@ -40,6 +40,7 @@ public sealed class GroupRolloverServiceTests Assert.True(target.IsDifferentiated); Assert.Null(target.WebUntisLessonId); Assert.False(source.IsActive); + Assert.Equal(source.Id, target.PreviousGroupId); var copied = Assert.Single(memberships.GetByGroup(target.Id)); Assert.NotEqual(oldMembership.Id, copied.Id); diff --git a/TODO.md b/TODO.md index dc38c96..ad2adbb 100644 --- a/TODO.md +++ b/TODO.md @@ -2530,6 +2530,19 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen. die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle Bearbeitung, Suche und Archivierung. +- [x] **7.2.7** Schuljahresübergreifende Auswertung, erster Baustein: Klausurschnitt-Vergleich + mit der Vorgängergruppe (Nutzer-Feedback). Neues Feld `LearningGroup.PreviousGroupId`, von + `GroupRolloverService.RollOver` (7.2.2) automatisch auf die Ausgangsgruppe gesetzt — vor + dieser Ergänzung hochgestufte Gruppen bleiben unverknüpft, ein rückwirkendes manuelles + Verknüpfen ist bewusst nicht Teil dieses Durchgangs. Neue Karte "Vorjahresvergleich" im + Kurs-Tab "Übersicht" (`GroupOverviewViewModel`) zeigt den gepoolten Klausurdurchschnitt + (alle nicht-abwesenden `ExamResult`-Einträge über alle Klausuren der Gruppe, via + `GradingService.WeightedAverage`) dieses Jahres neben dem der verknüpften Vorgängergruppe; + nur sichtbar, wenn im Vorjahr tatsächlich Klausurergebnisse vorliegen. **Bewusst + zurückgestellt:** ein Vergleich, welcher Notenschlüssel bei welcher Aufgabenstruktur wie + gewirkt hat — `Exam.GradingKey` ist eine reine Momentaufnahme ohne Verweis auf die + verwendete `GradingKeyTemplate`, eine saubere Gruppierung dafür bräuchte zunächst diesen + Verweis. ### 7.3 Import